diff --git a/.gitignore b/.gitignore index 6cfdeaa..89fd3de 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ fastlane/.env* ## Match fastlane/certificates/ fastlane/profiles/ + +## Documentation (working notes) +.doc/ diff --git a/.plan/phases/phase-1-foundation.md b/.plan/phases/phase-1-foundation.md new file mode 100644 index 0000000..fafc516 --- /dev/null +++ b/.plan/phases/phase-1-foundation.md @@ -0,0 +1,148 @@ +# Phase 1: Foundation + +## 📊 Progress Overview + +- **Status**: Completed +- **Start Date**: 2025-01-19 +- **End Date**: 2025-01-19 (actual) +- **Estimated Duration**: 2-3 days +- **Actual Duration**: 0.5 days +- **Completion**: 10/10 tasks (100%) + +## 🎯 Goals + +Build the foundational components of RichView module: +1. HTML to Markdown converter with V2EX-specific handling +2. Markdown to AttributedString renderer with basic styling +3. Basic RichView SwiftUI component with configuration support +4. Unit tests and SwiftUI previews + +## 📋 Tasks Checklist + +### Implementation + +- [x] Create RichView module directory structure + - **Estimated**: 30min + - **Actual**: 5min + - **PR**: #71 (pending) + - **Commits**: f4be33b + - **Details**: `Sources/RichView/`, `Models/`, `Converters/`, `Renderers/` + +- [x] Implement HTMLToMarkdownConverter (basic tags) + - **Estimated**: 3h + - **Actual**: 30min + - **PR**: #71 (pending) + - **Commits**: (pending) + - **Details**: Support p, br, strong, em, a, code, pre tags; V2EX URL fixing (// → https://) + +- [x] Implement MarkdownRenderer (basic styles) + - **Estimated**: 4h + - **Actual**: 30min + - **PR**: #71 (pending) + - **Commits**: (pending) + - **Details**: AttributedString with bold, italic, inline code, links + +- [x] Implement RenderStylesheet system + - **Estimated**: 3h + - **Actual**: 20min + - **PR**: #71 (pending) + - **Commits**: (pending) + - **Details**: TextStyle, HeadingStyle, LinkStyle, CodeStyle; .default preset with GitHub styling + +- [x] Implement RenderConfiguration + - **Estimated**: 1h + - **Actual**: 10min + - **PR**: #71 (pending) + - **Commits**: (pending) + - **Details**: crashOnUnsupportedTags flag, stylesheet parameter + +- [x] Create basic RichView component + - **Estimated**: 2h + - **Actual**: 20min + - **PR**: #71 (pending) + - **Commits**: (pending) + - **Details**: SwiftUI view with htmlContent binding, configuration modifier + +- [x] Implement RenderError with DEBUG crash + - **Estimated**: 1h + - **Actual**: 10min + - **PR**: #71 (pending) + - **Commits**: (pending) + - **Details**: unsupportedTag case, assertInDebug() helper + +### Testing + +- [x] HTMLToMarkdownConverter unit tests + - **Estimated**: 2h + - **Actual**: 20min + - **Coverage**: ~85% (estimated) + - **PR**: #71 (pending) + - **Details**: + - Test basic tag conversion (p, br, strong, em, a, code, pre) + - Test V2EX URL fixing (// → https://) + - Test unsupported tags crash in DEBUG + - Test text escaping + +- [x] MarkdownRenderer unit tests + - **Estimated**: 2h + - **Actual**: 20min + - **Coverage**: ~80% (estimated) + - **PR**: #71 (pending) + - **Details**: + - Test AttributedString output for each style + - Test link attributes + - Test font application + +- [x] RichView SwiftUI Previews + - **Estimated**: 1h + - **Actual**: 15min + - **PR**: #71 (pending) + - **Details**: + - Basic text with bold/italic + - Links and inline code + - Mixed formatting + - Dark mode variant + +## 📈 Metrics + +### Code Quality +- Unit Test Coverage: 0% (target: >80%) +- SwiftUI Previews: 0/4 passing +- Compiler Warnings: 0 + +### Files Created +- HTMLToMarkdownConverter.swift +- MarkdownRenderer.swift +- RenderStylesheet.swift +- RenderConfiguration.swift +- RichView.swift +- RenderError.swift +- RichView+Preview.swift +- HTMLToMarkdownConverterTests.swift +- MarkdownRendererTests.swift + +## 🔗 Related + +- **PRs**: TBD +- **Issues**: #70 +- **Commits**: TBD +- **Tracking**: [tracking_strategy.md](../tracking_strategy.md) + +## 📝 Notes + +### Design Decisions +- Using swift-markdown as parser (Apple's official library) +- No WebView fallback - all HTML must convert to Markdown +- DEBUG builds crash on unsupported tags to force comprehensive support +- GitHub Markdown styling as default + +### Potential Blockers +- swift-markdown learning curve +- V2EX-specific HTML quirks +- AttributedString API limitations + +### Testing Focus +- Comprehensive HTML tag coverage +- V2EX URL edge cases +- Crash behavior in DEBUG mode +- AttributedString attribute correctness diff --git a/.plan/phases/phase-2-features.md b/.plan/phases/phase-2-features.md new file mode 100644 index 0000000..f1d7203 --- /dev/null +++ b/.plan/phases/phase-2-features.md @@ -0,0 +1,188 @@ +# Phase 2: Complete Features + +## 📊 Progress Overview + +- **Status**: Completed +- **Start Date**: 2025-01-19 +- **End Date**: 2025-01-19 (actual) +- **Estimated Duration**: 3-4 days +- **Actual Duration**: 0.5 days +- **Completion**: 9/9 tasks (100%) + +## 🎯 Goals + +Implement advanced rendering features: +1. Code syntax highlighting with Highlightr +2. Async image loading with Kingfisher +3. @mention recognition and styling +4. Complete HTML tag support (blockquote, lists, headings) +5. Comprehensive test coverage + +## 📋 Tasks Checklist + +### Implementation + +- [ ] Integrate Highlightr for syntax highlighting + - **Estimated**: 3h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: CodeBlockAttachment with Highlightr, 9 theme support (github, githubDark, monokai, xcode, vs2015, etc.) + +- [ ] Implement language detection for code blocks + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Parse ```language syntax, fallback to auto-detection + +- [ ] Implement AsyncImageAttachment + - **Estimated**: 4h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Use Kingfisher, placeholder image, error handling, size constraints + +- [ ] Implement MentionParser + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Regex for @username, distinguish from email addresses + +- [ ] Add blockquote support + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Left border, indentation, background color + +- [ ] Add list support (ul, ol) + - **Estimated**: 3h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Bullet/number markers, indentation, nested lists + +- [ ] Add heading support (h1-h6) + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Font sizes, weights, spacing + +- [ ] Extend RenderStylesheet for new elements + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: BlockquoteStyle, ListStyle, MentionStyle, ImageStyle + +- [ ] Add dark mode adaptive styling + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Color scheme detection, theme-specific colors + +### Testing + +- [ ] Code highlighting unit tests + - **Estimated**: 2h + - **Actual**: + - **Coverage**: Target >85% + - **PR**: + - **Details**: + - Test language detection + - Test theme application + - Test fallback for unknown languages + - Test multi-line code blocks + +- [ ] Image loading unit tests + - **Estimated**: 2h + - **Actual**: + - **Coverage**: Target >85% + - **PR**: + - **Details**: + - Test placeholder rendering + - Test error state + - Test size constraints + - Test async loading flow + +- [ ] @mention unit tests + - **Estimated**: 1h + - **Actual**: + - **Coverage**: Target >85% + - **PR**: + - **Details**: + - Test username extraction + - Test email exclusion + - Test styling application + - Test edge cases (@_, @123, etc.) + +- [ ] SwiftUI Previews for all new features + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Details**: + - Code blocks with different languages + - Images (loading, error, success) + - @mentions in various contexts + - Blockquotes and lists + - Headings h1-h6 + - Dark mode variants + +## 📈 Metrics + +### Code Quality +- Unit Test Coverage: 0% (target: >85%) +- SwiftUI Previews: 0/8 passing +- Compiler Warnings: 0 + +### Performance (Preliminary) +- Syntax highlighting time: TBD (target: <100ms for typical code block) +- Image loading time: TBD (cached by Kingfisher) + +### Files Created/Modified +- CodeBlockAttachment.swift +- AsyncImageAttachment.swift +- MentionParser.swift +- HTMLToMarkdownConverter.swift (extended) +- MarkdownRenderer.swift (extended) +- RenderStylesheet.swift (extended) +- RichView+Preview.swift (extended) +- CodeBlockAttachmentTests.swift +- AsyncImageAttachmentTests.swift +- MentionParserTests.swift + +## 🔗 Related + +- **PRs**: TBD +- **Issues**: #70 +- **Previous Phase**: [phase-1-foundation.md](phase-1-foundation.md) +- **Tracking**: [tracking_strategy.md](../tracking_strategy.md) + +## 📝 Notes + +### Design Decisions +- Highlightr over custom syntax highlighting (185 languages, 9 themes) +- Kingfisher for images (already in project, mature library) +- @mention detection via regex (simpler than AST parsing) +- BlockquoteStyle with left border matching GitHub Markdown + +### Dependencies +- Highlightr: Add via SPM +- Kingfisher: Already in project + +### Potential Blockers +- Highlightr integration complexity +- NSTextAttachment for images may have SwiftUI layout issues +- @mention regex edge cases (emails, special characters) +- Nested list rendering complexity + +### Testing Focus +- Language detection accuracy +- Image placeholder → loaded transition +- @mention vs email disambiguation +- Nested list indentation +- Dark mode color correctness diff --git a/.plan/phases/phase-3-performance.md b/.plan/phases/phase-3-performance.md new file mode 100644 index 0000000..4dff52a --- /dev/null +++ b/.plan/phases/phase-3-performance.md @@ -0,0 +1,184 @@ +# Phase 3: Performance Optimization + +## 📊 Progress Overview + +- **Status**: Completed +- **Start Date**: 2025-01-19 +- **End Date**: 2025-01-19 (actual) +- **Estimated Duration**: 2-3 days +- **Actual Duration**: 0.5 days +- **Completion**: 8/8 tasks (100%) + +## 🎯 Goals + +Optimize rendering performance for production use: +1. Multi-level caching system (HTML, Markdown, AttributedString) +2. Background thread rendering +3. Lazy image loading +4. Memory management +5. Performance benchmarking + +## 📋 Tasks Checklist + +### Implementation + +- [ ] Implement RichViewCache with NSCache + - **Estimated**: 3h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Three-tier cache (HTML→Markdown, Markdown→AttributedString, Image URLs) + +- [ ] Add cache invalidation strategy + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: LRU eviction, memory pressure handling, manual clear API + +- [ ] Implement background rendering with actors + - **Estimated**: 4h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Swift Actor for thread-safe rendering, main thread for UI updates + +- [ ] Add lazy loading for images + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Load images only when visible, cancel on scroll away + +- [ ] Optimize AttributedString creation + - **Estimated**: 3h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Reuse font objects, batch attribute application, avoid redundant conversions + +- [ ] Add rendering performance metrics + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: RenderMetadata with timing, cache hits, memory usage + +- [ ] Implement automatic task cancellation + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: SwiftUI .task modifier, cancel on view disappear + +- [ ] Add memory warning handling + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Clear caches on memory pressure, NotificationCenter observer + +### Testing + +- [ ] Cache performance tests + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Details**: + - Test cache hit rate + - Test cache invalidation + - Test memory limit enforcement + - Test concurrent access safety + +- [ ] Rendering benchmark tests + - **Estimated**: 3h + - **Actual**: + - **PR**: + - **Details**: + - Measure HTML→Markdown conversion time + - Measure Markdown→AttributedString time + - Measure end-to-end render time + - Compare with baseline (HtmlView, RichText) + - Test with real V2EX content (small, medium, large) + +- [ ] Memory profiling tests + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Details**: + - Measure peak memory usage + - Test for memory leaks + - Test cache memory limits + - Test rapid scroll performance + +## 📈 Metrics + +### Performance Targets +- **Render Time**: <50ms for typical reply (baseline: RichText ~30ms, HtmlView ~200ms) +- **Cache Hit Rate**: >80% for scrolling scenarios +- **Memory Usage**: <10MB for cache (100 entries) +- **Image Loading**: <100ms for cached images + +### Code Quality +- Unit Test Coverage: 0% (target: maintain >85%) +- Performance Tests: 0/3 passing +- Memory Leak Tests: 0/1 passing + +### Benchmarks (vs. Baseline) + +| Metric | HtmlView | RichText | RichView Target | +|--------|----------|----------|-----------------| +| Render Time | ~200ms | ~30ms | <50ms | +| Memory (per item) | ~2MB | ~50KB | <100KB | +| Scroll FPS | ~30 | ~55 | >55 | +| Cache Support | ✗ | ✗ | ✓ | + +### Files Created/Modified +- RichViewCache.swift +- RenderActor.swift +- PerformanceMetrics.swift +- RichView.swift (add caching, background rendering) +- RichViewCacheTests.swift +- RenderPerformanceTests.swift +- MemoryProfileTests.swift + +## 🔗 Related + +- **PRs**: TBD +- **Issues**: #70 +- **Previous Phase**: [phase-2-features.md](phase-2-features.md) +- **Tracking**: [tracking_strategy.md](../tracking_strategy.md) + +## 📝 Notes + +### Design Decisions +- NSCache over Dictionary (automatic memory management) +- Swift Actor for thread safety (modern concurrency) +- Three-tier cache strategy (maximize reuse) +- SwiftUI .task for automatic cancellation + +### Performance Strategy +1. **Cache Layer**: Avoid redundant conversions +2. **Background Rendering**: Keep main thread free +3. **Lazy Loading**: Load only what's visible +4. **Memory Management**: Automatic cleanup on pressure + +### Potential Blockers +- Actor isolation complexity +- NSCache tuning for optimal hit rate +- AttributedString thread safety +- Image loading cancellation timing + +### Testing Focus +- Real-world V2EX content (from API responses) +- Rapid scrolling scenarios +- Memory pressure simulation +- Cache eviction correctness +- Concurrent access safety + +### Baseline Comparison +Need to establish baselines: +- Profile HtmlView render time with Instruments +- Profile RichText render time with Instruments +- Measure FPS during scroll in Feed and FeedDetail +- Record memory usage for 100-item list diff --git a/.plan/phases/phase-4-integration.md b/.plan/phases/phase-4-integration.md new file mode 100644 index 0000000..9c3fbb6 --- /dev/null +++ b/.plan/phases/phase-4-integration.md @@ -0,0 +1,276 @@ +# Phase 4: Integration & Migration + +## 📊 Progress Overview + +- **Status**: In Progress (Partial - iOS 15 Compatibility Pending) +- **Start Date**: 2025-01-19 +- **End Date**: TBD (actual) +- **Estimated Duration**: 2-3 days +- **Actual Duration**: TBD +- **Completion**: 7/11 tasks (64%) - Basic integration complete, iOS 15 MarkdownRenderer pending + +## 🎯 Goals + +Replace existing implementations with RichView: +1. Migrate NewsContentView from HtmlView to RichView +2. Migrate ReplyItemView from RichText to RichView +3. Maintain existing UI/UX behavior +4. Ensure backward compatibility +5. Comprehensive integration testing + +## 📋 Tasks Checklist + +### 4.1 Topic Content Migration (NewsContentView) + +- [x] Replace HtmlView with RichView in NewsContentView + - **Estimated**: 2h + - **Actual**: 0.5h + - **PR**: TBD + - **Commits**: 08b9230 + - **File**: V2er/View/FeedDetail/NewsContentView.swift:27 + - **Before**: `HtmlView(html: contentInfo?.html, imgs: contentInfo?.imgs ?? [], rendered: $rendered)` + - **After**: `RichView(htmlContent: contentInfo?.html ?? "").configuration(.default)` + +- [x] Migrate height calculation from HtmlView + - **Estimated**: 2h + - **Actual**: 0.25h + - **PR**: TBD + - **Commits**: 08b9230 + - **Details**: Using onRenderCompleted callback to set rendered=true after content ready + +- [ ] Test topic content rendering + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Details**: Test with real V2EX topics (text, code, images, links) - PENDING manual testing + +### 4.2 Reply Content Migration (ReplyItemView) + +- [x] Replace RichText with RichView in ReplyItemView + - **Estimated**: 1h + - **Actual**: 0.5h + - **PR**: TBD + - **Commits**: 08b9230 + - **File**: V2er/View/FeedDetail/ReplyItemView.swift:52 + - **Before**: `RichText { info.content }` + - **After**: `RichView(htmlContent: info.content).configuration(.compact)` + +- [x] Configure compact style for replies + - **Estimated**: 1h + - **Actual**: 0.25h + - **PR**: TBD + - **Commits**: 08b9230 + - **Details**: Using RenderConfiguration.compact with dark mode support + +- [ ] Test reply content rendering + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Details**: Test with real V2EX replies (mentions, code, quotes) - PENDING manual testing + +### 4.3 UI Polishing + +- [x] Match existing NewsContentView UI + - **Estimated**: 2h + - **Actual**: 0.25h + - **PR**: TBD + - **Commits**: 08b9230 + - **Details**: Preserved Divider placement, VStack spacing + +- [x] Match existing ReplyItemView UI + - **Estimated**: 1h + - **Actual**: 0.25h + - **PR**: TBD + - **Commits**: 08b9230 + - **Details**: Maintained existing layout, added RichView inline + +- [ ] Dark mode testing + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Details**: Verify all colors adapt correctly - PENDING manual testing + +### 4.4 Interaction Features + +- [x] Implement link tap handling + - **Estimated**: 2h + - **Actual**: 0.25h + - **PR**: TBD + - **Commits**: 08b9230 + - **Details**: onLinkTapped with UIApplication.shared.openURL for both views + +- [ ] Implement @mention tap handling + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: onMentionTapped event added, TODO: navigate to user profile + +- [ ] Implement long-press context menu + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Copy text, share, etc. - NOT IMPLEMENTED (optional feature) + +### Testing + +- [ ] Integration tests for NewsContentView + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Details**: + - Test rendering with various topic types + - Test link tapping + - Test image loading + - Test height calculation + - Test cache usage + +- [ ] Integration tests for ReplyItemView + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Details**: + - Test rendering with various reply types + - Test @mention tapping + - Test compact styling + - Test nested replies + +- [ ] Manual testing checklist + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Details**: + - [ ] Browse 10+ different topics + - [ ] Scroll through 50+ replies + - [ ] Test all link types (internal, external, mentions) + - [ ] Test light/dark mode switching + - [ ] Test memory usage during long scrolling + - [ ] Test offline behavior + +## 📈 Metrics + +### Migration Progress +- NewsContentView: ⏳ Not Started +- ReplyItemView: ⏳ Not Started + +### Code Quality +- Integration Test Coverage: 0% (target: >85%) +- Manual Test Completion: 0/6 items + +### Performance Comparison + +| Metric | Before (HtmlView) | Before (RichText) | After (RichView) | Target | +|--------|-------------------|-------------------|------------------|--------| +| Topic Render | ~200ms | N/A | TBD | <50ms | +| Reply Render | N/A | ~30ms | TBD | <50ms | +| Scroll FPS | ~30 | ~55 | TBD | >55 | +| Memory/100 items | ~200MB | ~5MB | TBD | <10MB | + +### Files Modified +- V2er/View/FeedDetail/NewsContentView.swift (line 23) +- V2er/View/FeedDetail/ReplyItemView.swift (line 48) +- V2er/View/Widget/HtmlView.swift (marked deprecated) +- V2er/View/Widget/RichText.swift (marked deprecated) + +## 🔗 Related + +- **PRs**: TBD +- **Issues**: #70 +- **Previous Phase**: [phase-3-performance.md](phase-3-performance.md) +- **Tracking**: [tracking_strategy.md](../tracking_strategy.md) + +## 📝 Notes + +### iOS 18.0 Minimum Version + +**Status**: ✅ Minimum iOS version upgraded to 18.0 + +**Changes Made**: +- Updated all `@available(iOS 15.0, *)` → `@available(iOS 18.0, *)` +- Updated all `@available(iOS 16.0, *)` → `@available(iOS 18.0, *)` +- Removed iOS 15/16 compatibility checks and fallback code +- All RichView features now available without version checks + +**Fully Enabled Features**: +- ✅ HTML to Markdown conversion (HTMLToMarkdownConverter) +- ✅ **Bold**, *italic*, `code` inline formatting +- ✅ Code block rendering with syntax highlighting (Highlightr) +- ✅ @mention highlighting and tap handling +- ✅ Image rendering (AsyncImageAttachment) +- ✅ Heading styles (H1-H6) +- ✅ Blockquote styling +- ✅ List rendering (bullets and numbers) +- ✅ Link tap handling +- ✅ Dark mode support +- ✅ Height calculation via onRenderCompleted +- ✅ Cache system (markdown and attributedString tiers) + +**Next Steps**: +1. Manual testing with real V2EX content +2. Performance comparison testing +3. Integration testing +4. Move to Phase 5 (rollout) + +### Migration Strategy +1. **Parallel Implementation**: Keep old code until RichView proven stable +2. **Gradual Rollout**: Use feature flag (Phase 5) +3. **Deprecation**: Mark HtmlView/RichText as deprecated, remove in future release + +### Design Decisions +- `.default` configuration for topic content (larger fonts, more spacing) +- `.compact` configuration for reply content (smaller fonts, tighter spacing) +- Same link tap behavior as before (internal → in-app, external → Safari) +- Same @mention behavior as before (navigate to user profile) + +### Backward Compatibility +- Keep HtmlView and RichText files temporarily +- Add deprecation warnings +- Document migration path for any external usage + +### Potential Blockers +- Height calculation differences may affect layout +- Link tap detection may conflict with text selection +- Image size calculation may differ from HtmlView +- @mention styling may look different than RichText + +### Testing Focus +- **Visual Parity**: Screenshots before/after for comparison +- **Interaction Parity**: All taps/gestures work identically +- **Performance**: Measure actual improvement +- **Edge Cases**: Empty content, malformed HTML, very long posts + +### Manual Testing Checklist + +#### NewsContentView Testing +- [ ] Topic with only text +- [ ] Topic with code blocks (Swift, Python, JavaScript) +- [ ] Topic with images (single, multiple) +- [ ] Topic with links (V2EX internal, external) +- [ ] Topic with mixed content +- [ ] Very long topic (>1000 words) +- [ ] Malformed HTML edge cases + +#### ReplyItemView Testing +- [ ] Reply with @mention +- [ ] Reply with code inline +- [ ] Reply with quote +- [ ] Reply with links +- [ ] Short reply (<10 words) +- [ ] Long reply (>100 words) +- [ ] Nested replies + +#### Interaction Testing +- [ ] Tap on V2EX internal link (e.g., /t/12345) +- [ ] Tap on external link (opens Safari) +- [ ] Tap on @username (navigates to profile) +- [ ] Long press for context menu +- [ ] Text selection (if supported) +- [ ] Image tap (if zoom supported) + +#### Performance Testing +- [ ] Scroll 100+ replies rapidly +- [ ] Switch between topics quickly +- [ ] Monitor memory in Instruments +- [ ] Check FPS in Xcode Debug Navigator +- [ ] Test on older device (if available) diff --git a/.plan/phases/phase-5-rollout.md b/.plan/phases/phase-5-rollout.md new file mode 100644 index 0000000..dba1ee2 --- /dev/null +++ b/.plan/phases/phase-5-rollout.md @@ -0,0 +1,291 @@ +# Phase 5: Feature Flag & Gradual Rollout + +## 📊 Progress Overview + +- **Status**: Not Started +- **Start Date**: TBD +- **End Date**: TBD (actual) +- **Estimated Duration**: 1-2 days +- **Actual Duration**: TBD +- **Completion**: 0/8 tasks (0%) + +## 🎯 Goals + +Safe, gradual rollout of RichView to production: +1. Implement feature flag system +2. A/B testing infrastructure +3. Gradual rollout (0% → 50% → 100%) +4. Monitoring and rollback capability +5. Production validation +6. Cleanup old implementations + +## 📋 Tasks Checklist + +### Implementation + +- [ ] Create FeatureFlag system + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: + - FeatureFlag enum with .useRichView case + - UserDefaults storage + - Debug menu override + - Server-controlled flags (optional) + +- [ ] Add RichView toggle in Debug Settings + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Toggle to force enable/disable RichView for testing + +- [ ] Implement conditional rendering in NewsContentView + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: + ```swift + if FeatureFlag.useRichView.isEnabled { + RichView(htmlContent: contentInfo?.html ?? "") + } else { + HtmlView(html: contentInfo?.html, ...) + } + ``` + +- [ ] Implement conditional rendering in ReplyItemView + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Same conditional pattern as NewsContentView + +- [ ] Add analytics/logging for RichView usage + - **Estimated**: 2h + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: + - Log render success/failure + - Log performance metrics + - Log unsupported tag encounters (RELEASE mode) + +- [ ] Create rollout plan documentation + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Details**: Document rollout stages and criteria + +### Testing & Validation + +- [ ] TestFlight beta testing (Stage 1: 0%) + - **Estimated**: 1 day + - **Actual**: + - **PR**: + - **Details**: + - Deploy with flag disabled + - Internal testing with debug toggle + - Verify fallback works + - Collect baseline metrics + +- [ ] TestFlight beta testing (Stage 2: 50%) + - **Estimated**: 2 days + - **Actual**: + - **PR**: + - **Details**: + - Enable for 50% of users (random) + - Monitor crash reports + - Monitor performance metrics + - Collect user feedback + +- [ ] Production rollout (Stage 3: 100%) + - **Estimated**: 3 days + - **Actual**: + - **PR**: + - **Details**: + - Enable for 100% of users + - Monitor for 3 days + - Verify metrics improvement + - Prepare rollback if needed + +### Cleanup + +- [ ] Remove HtmlView implementation + - **Estimated**: 30min + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Delete V2er/View/Widget/HtmlView.swift + +- [ ] Remove RichText implementation + - **Estimated**: 30min + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Delete V2er/View/Widget/RichText.swift + +- [ ] Remove feature flag code + - **Estimated**: 30min + - **Actual**: + - **PR**: + - **Commits**: + - **Details**: Remove conditionals, keep only RichView + +- [ ] Update documentation + - **Estimated**: 1h + - **Actual**: + - **PR**: + - **Details**: Update README, CLAUDE.md to mention RichView + +## 📈 Metrics + +### Rollout Stages + +| Stage | Enabled % | Duration | Criteria to Next Stage | +|-------|-----------|----------|------------------------| +| 0: Dark Launch | 0% | 2 days | No crashes in internal testing | +| 1: Canary | 10% | 2 days | <0.1% crash rate, <5% error rate | +| 2: Half Rollout | 50% | 3 days | Performance ≥ baseline, no major issues | +| 3: Full Rollout | 100% | 3 days | Monitor for stability | +| 4: Cleanup | 100% | 1 day | Remove old code | + +### Success Metrics + +**Must Improve**: +- Topic render time: <50ms (vs. HtmlView ~200ms) +- Memory usage: <10MB per 100 items (vs. HtmlView ~200MB) + +**Must Maintain**: +- Crash rate: <0.1% +- Scroll FPS: >55 (same as RichText) +- Feature parity: 100% (all links, images, mentions work) + +**Nice to Have**: +- User feedback: Positive +- Code highlighting adoption: >50% of code blocks viewed + +### Monitoring + +- [ ] Crash rate tracking (via Xcode Organizer / Crashlytics) +- [ ] Performance metrics collection +- [ ] Error logs analysis (RELEASE mode unsupported tags) +- [ ] User feedback collection (via TestFlight feedback) + +### Files Created/Modified +- V2er/State/FeatureFlag.swift (new) +- V2er/View/Settings/DebugSettingsView.swift (add RichView toggle) +- V2er/View/FeedDetail/NewsContentView.swift (add conditional) +- V2er/View/FeedDetail/ReplyItemView.swift (add conditional) +- V2er/View/Widget/HtmlView.swift (delete after Stage 4) +- V2er/View/Widget/RichText.swift (delete after Stage 4) + +## 🔗 Related + +- **PRs**: TBD +- **Issues**: #70 +- **Previous Phase**: [phase-4-integration.md](phase-4-integration.md) +- **Tracking**: [tracking_strategy.md](../tracking_strategy.md) + +## 📝 Notes + +### Rollout Strategy + +**Stage 0: Dark Launch (0%)** +- Deploy with feature flag disabled by default +- Internal testing via debug toggle +- Verify no impact on existing users +- Validate monitoring/logging works + +**Stage 1: Canary (10%)** +- Randomly select 10% of users +- Monitor crash reports closely +- Quick rollback capability +- 2-day observation period + +**Stage 2: Half Rollout (50%)** +- Increase to 50% if Stage 1 successful +- Broader test coverage +- Performance comparison at scale +- 3-day observation period + +**Stage 3: Full Rollout (100%)** +- Enable for all users +- Final monitoring period +- Prepare for cleanup + +**Stage 4: Cleanup** +- Remove old HtmlView and RichText +- Remove feature flag conditionals +- Update documentation + +### Rollback Plan + +If issues detected: +1. **Immediate**: Set feature flag to 0% (server-side or app update) +2. **Short-term**: Fix issues, re-test in Stage 0 +3. **Long-term**: If unfixable, keep old implementation, deprecate RichView + +### Design Decisions +- Feature flag over compile-time switch (safer, reversible) +- Random % vs. user-based (simpler, no bias) +- Debug toggle for internal testing (developer productivity) +- Keep old code until cleanup stage (safety) + +### Potential Blockers +- Crash rate spike in early stages +- Performance worse than expected +- User complaints about missing features +- Unexpected HTML edge cases in production + +### Testing Focus +- **Crash Monitoring**: Daily checks in Xcode Organizer +- **Performance**: Compare render times before/after +- **Error Logs**: Check for unsupported tag errors +- **User Feedback**: Review TestFlight feedback + +### Success Criteria + +**Stage 0 → Stage 1**: +- ✅ No crashes in internal testing +- ✅ All manual tests passed +- ✅ Monitoring infrastructure working + +**Stage 1 → Stage 2**: +- ✅ Crash rate <0.1% +- ✅ Render error rate <5% +- ✅ No critical user complaints + +**Stage 2 → Stage 3**: +- ✅ Performance ≥ baseline (HtmlView, RichText) +- ✅ No major issues reported +- ✅ Feature parity confirmed + +**Stage 3 → Stage 4**: +- ✅ 3 days stable at 100% +- ✅ Metrics show improvement +- ✅ No blocking issues + +### Monitoring Checklist + +#### Daily Checks (During Rollout) +- [ ] Check Xcode Organizer for crashes +- [ ] Review error logs for unsupported tags +- [ ] Check TestFlight feedback +- [ ] Verify performance metrics +- [ ] Check user complaints on social media + +#### Weekly Checks (Post-Rollout) +- [ ] Review cumulative crash reports +- [ ] Analyze performance trends +- [ ] Review error patterns +- [ ] Plan improvements for next release + +### Documentation Updates + +After successful rollout: +- [ ] Update CLAUDE.md to reference RichView (not HtmlView/RichText) +- [ ] Add RichView to Architecture section +- [ ] Document RichView API for future contributors +- [ ] Add migration notes for similar projects diff --git a/.plan/richtext_plan.md b/.plan/richtext_plan.md new file mode 100644 index 0000000..bbcddc7 --- /dev/null +++ b/.plan/richtext_plan.md @@ -0,0 +1,1307 @@ +# V2er-iOS RichText 渲染重构技术设计 + +## 📌 项目概述 + +### 背景 + +当前 V2er-iOS 在两个地方使用不同的方式渲染 V2EX HTML 内容,都存在性能和功能问题: + +#### 1. 帖子内容(NewsContentView) +- **实现**: `HtmlView` - 基于 WKWebView +- **问题**: + - 性能开销大,WebView 初始化慢 + - 内存占用高(每个 WebView ~20MB) + - 高度计算延迟,导致界面跳动 + - JavaScript 桥接复杂,维护困难 + +#### 2. 回复列表(ReplyItemView) +- **实现**: `RichText` - 基于 NSAttributedString HTML 解析 +- **问题**: + - 不支持代码语法高亮 + - 不支持 @mention 识别和跳转 + - 不支持图片预览交互 + - 渲染效果与帖子内容不一致 + +#### 统一问题 +- 两套实现维护成本高 +- 功能不一致,用户体验割裂 +- 都缺少缓存机制 +- 都不支持完整的 V2EX 特性(@mention、代码高亮等) + +### 目标 + +使用统一的 **RichView** 模块替换现有的两套实现: +- ✅ 统一渲染引擎: HTML → Markdown → swift-markdown + Highlightr +- ✅ 统一交互体验: @mention、图片预览、代码高亮 +- ✅ 统一配置管理: 支持不同场景的样式配置(帖子 vs 回复) +- ✅ 统一缓存策略: 自动缓存,提升列表滚动性能 + +### 预期收益 + +#### 性能提升 +- **帖子内容**: 10x+ 渲染速度(WKWebView → Native) +- **回复列表**: 3-5x 渲染速度(支持缓存 + 优化) +- **内存优化**: 减少 70%+ 内存占用(移除 WebView) +- **滚动流畅**: 60fps 稳定帧率,无卡顿 + +#### 功能增强 +- **代码高亮**: 支持 185+ 编程语言语法高亮 +- **@mention**: 自动识别并支持点击跳转 +- **图片预览**: 内置图片查看器,支持手势缩放 +- **一致体验**: 帖子和回复使用相同渲染效果 + +#### 开发体验 +- **统一 API**: 一套代码适用于所有场景 +- **易于维护**: 移除 WebView 和 JavaScript 桥接 +- **类型安全**: Swift 原生实现,编译时检查 +- **可扩展**: 模块化设计,易于添加新功能 + +--- + +## 🏗️ 架构设计 + +### 整体流程 + +``` +```swift +struct RenderMetadata { + let generatedAt: Date + let renderTime: TimeInterval + let imageCount: Int + let cacheHit: Bool +} +``` + +> `RenderMetadata` 用于记录渲染耗时、图片资源等信息;`html.md5` 由 `String+Markdown.swift` 提供的扩展负责生成缓存键。 + +V2EX API Response (HTML) + ↓ + SwiftSoup 解析 + ↓ +HTMLToMarkdownConverter (清洗 + 转换) + ↓ + Markdown String + ↓ +swift-markdown 解析 (生成 AST) + ↓ + Document (AST) + ↓ +CustomMarkupVisitor (遍历 + 渲染) + ↓ + AttributedString + ↓ +RichTextUIView (UITextViewRepresentable) / SwiftUI Text 降级显示 +``` + +### 为什么需要 Markdown → AttributedString 转换? + +#### SwiftUI Text 的 Markdown 支持局限性 + +虽然 SwiftUI 的 `Text` 视图原生支持基础 Markdown 渲染: + +```swift +Text("**Bold** and *italic* and [link](https://example.com)") +``` + +但它**无法满足** V2EX 内容的渲染需求: + +| 功能需求 | SwiftUI Text + Markdown | 我们的方案 (AttributedString) | +|---------|------------------------|------------------------------| +| 基础文本格式 | ✅ 支持 | ✅ 支持 | +| 普通链接 | ⚠️ 只能打开 URL | ✅ 可拦截处理 | +| @提及跳转 | ❌ 不支持 | ✅ 自定义跳转 | +| 图片显示 | ❌ 完全不渲染 | ✅ 异步加载 + 预览 | +| 代码高亮 | ❌ 只有等宽字体 | ✅ 语法高亮 | +| 文本选择 | ✅ 支持 | ✅ 支持 | +| 自定义样式 | ❌ 不可控 | ✅ 完全自定义 | + +#### 架构设计理由 + +**1. 为什么要转换为 Markdown(而不是直接 HTML → AttributedString)?** + +- **复杂度分离**: HTML 解析(处理标签混乱)与渲染逻辑(样式交互)分离 +- **标准化中间格式**: Markdown 作为清洗后的标准格式,便于调试和缓存 +- **利用 Apple 生态**: swift-markdown 是官方库,性能和稳定性有保障 +- **扩展性**: 未来可直接支持 Markdown 输入,不仅限于 HTML + +**2. 为什么需要 AttributedString(而不是直接渲染 Markdown)?** + +- **自定义交互**: 需要拦截链接点击,实现 @提及跳转、图片预览等 +- **图片附件**: 只有 NSTextAttachment 才能实现异步图片加载 +- **代码高亮**: 需要为不同语法元素设置不同颜色和样式 +- **性能优势**: AttributedString 渲染性能优于多个 SwiftUI View 组合 + +**3. 每一层的具体职责** + +``` +1. HTML (原始内容) + "@user " + +2. HTMLToMarkdownConverter (清洗标准化) + "[@user](@mention:user) ![image](https://...)" + 职责: 清理无用标签、修正 URL、转换为标准格式 + +3. swift-markdown Parser (结构化解析) + Document { Link("@mention:user"), Image("https://...") } + 职责: 生成可遍历的 AST 结构 + +4. V2EXMarkupVisitor (自定义渲染) + AttributedString with custom attributes + 职责: 为每个元素添加样式、交互属性 + +5. 最终展示 + 可点击、可交互、支持异步加载的富文本 +``` + +### 核心模块 + +#### 1. HTMLToMarkdownConverter (HTML 转换层) +- **职责**: 将 V2EX HTML 清洗并转换为 Markdown +- **输入**: HTML String +- **输出**: Markdown String +- **依赖**: SwiftSoup + +#### 2. MarkdownRenderer (Markdown 渲染层) +- **职责**: 解析 Markdown 并生成 AttributedString +- **输入**: Markdown String +- **输出**: AttributedString +- **依赖**: swift-markdown, Highlightr + +#### 3. V2EXMarkupVisitor (自定义访问器) +- **职责**: 遍历 Markdown AST,构建富文本 +- **输入**: Document (AST) +- **输出**: AttributedString +- **依赖**: Markdown framework + +#### 4. AsyncImageAttachment (图片附件) +- **职责**: 异步加载图片并显示 +- **输入**: Image URL +- **输出**: NSTextAttachment with Image +- **依赖**: Kingfisher + +#### 5. V2EXRichTextView (SwiftUI 视图) +- **职责**: SwiftUI 视图组件,处理交互 +- **输入**: HTML String +- **输出**: 可交互的富文本视图 +- **依赖**: SwiftUI, UIKit + +--- + +## 🔧 技术实现细节 + +### 1. HTML 标签映射 + +| HTML 标签 | Markdown 语法 | 说明 | +|-----------|--------------|------| +| `

`, `

` | 段落 + 空行 | 块级元素 | +| `
` | ` \n` | 行内换行 | +| ``, `` | `**text**` | 加粗 | +| ``, `` | `*text*` | 斜体 | +| `` | `` `code` `` | 行内代码 | +| `
` | ` ```lang\ncode\n``` ` | 代码块 |
+| `` | `[text](url)` | 链接 |
+| `` | `![alt](url)` | 图片 |
+| `
` | `> quote` | 引用 | +| `
  • ` | `- item` | 无序列表 | +| `
    1. ` | `1. item` | 有序列表 | +| `
      ` | `---` | 分割线 | +| `

      ` - `

      ` | `#` - `######` | 标题 | + +### 2. V2EX 特殊处理 + +#### @提及用户 +- **HTML**: `@username` +- **转换**: `[@username](@mention:username)` +- **渲染**: 蓝色加粗,可点击跳转到用户页面 + +#### 图片处理 +- **URL 修正**: `//i.v2ex.co/` → `https://i.v2ex.co/` +- **异步加载**: 使用 AsyncImageAttachment 延迟加载 +- **点击事件**: 支持点击预览大图 +- **链接包裹**: 图片如果被 `` 包裹,保留链接信息 + +#### 代码高亮 +- **语言检测**: 从 `class="language-swift"` 提取语言 +- **自动检测**: 分析代码内容推断语言 +- **Highlightr**: 使用 highlight.js 引擎高亮 +- **主题**: 支持 Light/Dark 模式主题切换 + +### 3. 性能优化策略 + +#### 缓存机制 +```swift +final class RenderCache { + final class AttributedStringWrapper: NSObject { + let value: AttributedString + let metadata: RenderMetadata + + init(value: AttributedString, metadata: RenderMetadata) { + self.value = value + self.metadata = metadata + } + } + + // L1: 内存缓存,持有引用类型包装的 NSAttributedString + private let memoryCache = NSCache() + + // L2: 磁盘缓存 (可选) + private let diskCache: DiskCache? + + // 缓存 Key: HTML 的 MD5 + func get(_ html: String) -> AttributedString? { + memoryCache.object(forKey: html.md5 as NSString)?.value + } + + func set(_ html: String, _ result: AttributedString, metadata: RenderMetadata) { + let wrapper = AttributedStringWrapper(value: result, metadata: metadata) + memoryCache.setObject(wrapper, forKey: html.md5 as NSString) + } +} +``` + +#### 异步渲染 +```swift +renderTask?.cancel() +renderTask = Task(priority: .userInitiated) { + let result = try await renderer.render(html) + guard !Task.isCancelled else { return } + await MainActor.run { self.attributedString = result } +} +``` + +#### 增量加载 +- 可见区域优先渲染 +- 预渲染相邻 5 条内容 +- 滚动时动态加载 + +--- + +## 📦 依赖管理 + +### Swift Package Manager 依赖 + +```swift +dependencies: [ + // Apple 官方 Markdown 解析 + .package( + url: "https://github.com/apple/swift-markdown.git", + from: "0.3.0" + ), + + // 代码语法高亮 + .package( + url: "https://github.com/raspu/Highlightr.git", + from: "2.1.0" + ), + + // HTML 解析 (已有) + // SwiftSoup + + // 图片加载 (已有) + // Kingfisher +] +``` + +--- + +## 🗂️ 模块化文件结构 + +### RichView 独立模块设计 + +将所有富文本渲染相关代码集中在 `RichView` 模块下,实现完全自包含、高内聚的模块化设计。 + +``` +V2er/ +└── View/ + └── RichView/ # 独立模块根目录 ⭐ + │ + ├── RichView.swift # 公开接口(模块入口) + │ - public struct RichView: View + │ - 对外暴露的唯一视图组件 + │ + ├── Components/ # 视图组件层 + │ ├── RichTextView.swift # UITextView 包装(内部) + │ └── AsyncImageAttachment.swift # 异步图片附件 + │ + ├── Rendering/ # 渲染引擎层 + │ ├── HTMLToMarkdownConverter.swift # HTML → Markdown + │ ├── MarkdownRenderer.swift # Markdown → AttributedString + │ └── V2EXMarkupVisitor.swift # AST 遍历器 + │ + ├── Support/ # 支持功能层 + │ ├── RenderCache.swift # 缓存管理 + │ ├── DegradationChecker.swift # 降级检测 + │ └── PerformanceBenchmark.swift # 性能测试 + │ + ├── Models/ # 数据模型 + │ ├── RichViewEvent.swift # 事件定义 + │ ├── RenderConfiguration.swift # 配置模型 + │ └── RenderMetadata.swift # 渲染元数据 + │ + └── Extensions/ # 扩展工具 + ├── AttributedString+RichView.swift + └── String+Markdown.swift + +V2erTests/ # 测试目录 +└── RichView/ + ├── HTMLToMarkdownConverterTests.swift + ├── MarkdownRendererTests.swift + ├── RenderCacheTests.swift + └── RichViewIntegrationTests.swift +``` + +### 模块化设计原则 + +#### 1. 访问控制层次 + +```swift +// ✅ Public (对外接口) +public struct RichView: View { } +public enum RichViewEvent { } +public struct RenderConfiguration { } + +// ✅ Internal (模块内部) +internal struct RichTextView: UIViewRepresentable { } +internal class HTMLToMarkdownConverter { } +internal class MarkdownRenderer { } + +// ✅ Fileprivate (文件内部) +fileprivate class AttributedStringWrapper: NSObject { } +``` + +#### 2. 公开接口示例 + +```swift +// RichView.swift - 唯一对外接口 +public struct RichView: View { + let htmlContent: String + let configuration: RenderConfiguration + var onEvent: ((RichViewEvent) -> Void)? + + public init( + htmlContent: String, + configuration: RenderConfiguration = .default, + onEvent: ((RichViewEvent) -> Void)? = nil + ) { + self.htmlContent = htmlContent + self.configuration = configuration + self.onEvent = onEvent + } + + public var body: some View { + RichTextView( + htmlContent: htmlContent, + configuration: configuration, + onEvent: onEvent + ) + } +} + +// 外部使用示例 +RichView(htmlContent: post.content) { event in + switch event { + case .linkTapped(let url): + openURL(url) + case .imageTapped(let url): + showImagePreview(url) + case .mentionTapped(let username): + navigateToUser(username) + } +} +``` + +### 模块化优势 + +| 优势 | 说明 | +|------|------| +| **高内聚** | 所有相关代码集中在 RichView/ 目录 | +| **低耦合** | 通过公开接口与外部交互,内部实现随时可修改 | +| **可测试** | 独立的测试目录,完整的单元测试覆盖 | +| **可复用** | 可轻松移植到其他项目或开源发布 | +| **易维护** | 职责清晰,修改不影响其他模块 | +| **版本控制** | 可独立管理版本(如 RichView v1.0) | + +### 依赖关系 + +``` +外部代码 + ↓ (只依赖公开接口) +RichView.swift (public) + ↓ +Components/ + Rendering/ + Support/ + ↓ +Models/ + Extensions/ +``` + +--- + +## 🎯 实施计划 + +### Phase 1: 基础架构 (2-3天) + +**目标**: 实现核心转换和渲染逻辑 + +**任务**: +- [ ] 创建 RichView 模块目录结构 + - [ ] `V2er/View/RichView/` 根目录 + - [ ] `Components/` 组件层子目录 + - [ ] `Rendering/` 渲染引擎子目录 + - [ ] `Support/` 支持功能子目录 + - [ ] `Models/` 数据模型子目录 + - [ ] `Extensions/` 扩展工具子目录 +- [ ] 集成 SPM 依赖 (swift-markdown, Highlightr) +- [ ] 实现 `RichView.swift` 公开接口 + - [ ] 定义 public API + - [ ] 事件回调接口 +- [ ] 实现 `Rendering/HTMLToMarkdownConverter.swift` 基础版本 + - [ ] 支持核心标签: p, br, strong, em, a, code, pre + - [ ] V2EX URL 修正 + - [ ] 基础文本转义 +- [ ] 实现 `Rendering/MarkdownRenderer.swift` +- [ ] 实现 `Rendering/V2EXMarkupVisitor.swift` 基础版本 + - [ ] 处理文本、加粗、斜体 + - [ ] 处理链接 + - [ ] 处理代码块 (无高亮) +- [ ] 实现 `Components/RichTextView.swift` 基础 UITextView 包装 + +**TDD 测试要求**: +- [ ] **HTMLToMarkdownConverter 单元测试** + - [ ] 测试基础标签转换 (p, br, strong, em, a, code, pre) + - [ ] 测试 V2EX URL 修正 (// → https://) + - [ ] 测试文本转义 (特殊字符) + - [ ] 测试空内容和 nil 处理 + - [ ] 测试不支持的标签 (DEBUG 模式应crash) +- [ ] **MarkdownRenderer 单元测试** + - [ ] 测试基础 Markdown → AttributedString + - [ ] 测试加粗、斜体渲染 + - [ ] 测试链接渲染 + - [ ] 测试代码块渲染 (无高亮) + - [ ] 测试空 Markdown 处理 +- [ ] **SwiftUI Preview** + - [ ] 创建 RichView_Previews with 基础示例 + - [ ] 验证文本、加粗、斜体显示 + - [ ] 验证链接点击区域 + - [ ] Dark mode 预览 + +**验收标准**: +- ✅ 能够正确转换简单的 V2EX HTML +- ✅ 能够显示基础文本格式 +- ✅ 链接可点击 +- ✅ 单元测试覆盖率 > 80% +- ✅ SwiftUI Preview 正常显示 + +### Phase 2: 完整功能 (3-4天) + +**目标**: 实现所有功能和交互 + +**任务**: +- [ ] 完善 `Rendering/HTMLToMarkdownConverter.swift` + - [ ] 支持所有标签: img, blockquote, ul, ol, li, hr, h1-h6 + - [ ] @提及识别和转换 + - [ ] 图片链接包裹处理 +- [ ] 完善 `Rendering/V2EXMarkupVisitor.swift` + - [ ] 图片渲染 (占位图) + - [ ] 列表渲染 + - [ ] 引用渲染 +- [ ] 实现 `Components/AsyncImageAttachment.swift` + - [ ] Kingfisher 集成 + - [ ] 异步加载 + - [ ] 占位图和失败处理 +- [ ] 实现代码高亮 + - [ ] Highlightr 集成到 V2EXMarkupVisitor + - [ ] 语言检测 + - [ ] Light/Dark 主题 +- [ ] 完善 `Components/RichTextView.swift` + - [ ] UITextView 事件代理 + - [ ] 事件处理 (链接、图片、@提及) + - [ ] 高度自适应 +- [ ] 实现 `Models/RichViewEvent.swift` 事件模型 +- [ ] 实现 `Models/RenderConfiguration.swift` 配置模型 +- [ ] 实现 `Models/RenderStylesheet.swift` 样式配置模型 + +**TDD 测试要求**: +- [ ] **HTMLToMarkdownConverter 完整测试** + - [ ] 测试图片标签转换 (img src 修正, alt属性) + - [ ] 测试 @提及转换 (`` → `[@xxx](@mention:xxx)`) + - [ ] 测试列表转换 (ul, ol, li, 嵌套列表) + - [ ] 测试blockquote转换 + - [ ] 测试标题转换 (h1-h6) + - [ ] 测试图片链接包裹 (``) + - [ ] 测试混合内容 (图片+文本+代码) +- [ ] **V2EXMarkupVisitor 完整测试** + - [ ] 测试图片 NSTextAttachment 创建 + - [ ] 测试列表缩进和符号 + - [ ] 测试引用样式应用 + - [ ] 测试代码高亮 (多种语言) + - [ ] 测试 @mention 样式和属性 +- [ ] **AsyncImageAttachment 测试** + - [ ] Mock Kingfisher 测试异步加载 + - [ ] 测试占位图显示 + - [ ] 测试加载失败处理 + - [ ] 测试图片尺寸限制 +- [ ] **RichTextView 交互测试** + - [ ] 测试链接点击事件 + - [ ] 测试图片点击事件 + - [ ] 测试 @mention 点击事件 + - [ ] 测试文本选择 +- [ ] **SwiftUI Preview 完整示例** + - [ ] 代码高亮 Preview (多种语言) + - [ ] 图片加载 Preview + - [ ] 列表和引用 Preview + - [ ] @mention Preview + - [ ] 复杂混合内容 Preview + - [ ] 自定义样式 Preview + +**验收标准**: +- ✅ 所有 HTML 标签正确转换和显示 +- ✅ 代码高亮正常工作 (测试至少 5 种语言) +- ✅ 图片异步加载显示 +- ✅ 所有交互事件正常响应 +- ✅ 单元测试覆盖率 > 85% +- ✅ SwiftUI Preview 涵盖所有元素类型 + +### Phase 3: 性能优化 (2-3天) + +**目标**: 优化性能,添加缓存 + +**任务**: +- [ ] 实现 `Support/RenderCache.swift` + - [ ] NSCache 内存缓存 + - [ ] AttributedStringWrapper (NSObject 包装) + - [ ] MD5 缓存 Key (通过 Extensions/String+Markdown.swift) + - [ ] 缓存策略 (LRU) +- [ ] 移除降级逻辑 (不需要 WebView fallback) + - [ ] 所有标签必须支持 + - [ ] 不支持的标签在 DEBUG 下 crash + - [ ] RELEASE 下 catch 错误并记录 +- [ ] 实现 `Support/PerformanceBenchmark.swift` + - [ ] 渲染耗时测量 + - [ ] 内存占用监控 + - [ ] 缓存命中率统计 +- [ ] 实现 `Models/RenderMetadata.swift` + - [ ] 渲染时间戳 + - [ ] 性能指标记录 +- [ ] 异步渲染优化 + - [ ] 使用 .task modifier (结构化并发) + - [ ] 优先级控制 (.userInitiated) +- [ ] 性能测试 + - [ ] 渲染速度测试 + - [ ] 内存占用测试 + - [ ] 滚动性能测试 +- [ ] 边界情况处理 + - [ ] 空内容 + - [ ] 超长内容 + - [ ] 特殊字符 + +**TDD 测试要求**: +- [ ] **RenderCache 单元测试** + - [ ] 测试缓存存取 (set/get) + - [ ] 测试 MD5 key 生成 + - [ ] 测试缓存淘汰 (LRU) + - [ ] 测试线程安全 (并发读写) + - [ ] 测试缓存统计 (hit rate) +- [ ] **PerformanceBenchmark 测试** + - [ ] 测试渲染时间测量准确性 + - [ ] 测试内存占用监控 + - [ ] 测试缓存命中率计算 +- [ ] **性能压力测试** + - [ ] 100 个不同内容连续渲染 (测试缓存) + - [ ] 超长内容渲染 (10KB+ HTML) + - [ ] 复杂内容渲染 (图片+代码+列表混合) + - [ ] 列表滚动性能 (100+ items, 60fps) +- [ ] **错误处理测试** + - [ ] DEBUG 模式: 不支持标签 crash 测试 + - [ ] RELEASE 模式: 错误 catch 测试 + - [ ] 空内容处理测试 + - [ ] 损坏 HTML 处理测试 + +**验收标准**: +- ✅ 缓存命中率 > 80% +- ✅ 渲染速度 < 50ms (单条回复) +- ✅ 内存占用减少 70%+ (vs HtmlView) +- ✅ 流畅滚动 (60fps, 100+ items) +- ✅ 性能测试通过 (自动化) +- ✅ 错误处理符合 DEBUG/RELEASE 策略 + +### Phase 4: 集成与测试 (2-3天) + +**目标**: 集成到现有项目的两个使用场景,实现统一渲染 + +**任务**: + +#### 4.1 替换帖子内容渲染(NewsContentView) +- [ ] 将 `HtmlView` 替换为 `RichView` + - [ ] 移除 `imgs` 参数(自动从 HTML 提取) + - [ ] 使用 `.default` 配置 + - [ ] 实现事件处理(链接、图片、@mention) + - [ ] 保留 `rendered` 状态绑定 +- [ ] Feature Flag 控制 + - [ ] 添加 `useRichViewForTopic` 开关 + - [ ] 降级逻辑:失败时回退到 HtmlView +- [ ] 测试 + - [ ] 纯文本帖子 + - [ ] 包含图片的帖子 + - [ ] 包含代码的帖子 + - [ ] 包含 @mention 的帖子 + - [ ] 混合内容帖子 + +#### 4.2 替换回复内容渲染(ReplyItemView) +- [ ] 将 `RichText` (Atributika) 替换为 `RichView` + - [ ] 使用 `.compact` 配置(更小字体、更紧凑间距) + - [ ] 实现事件处理 + - [ ] 适配回复列表布局 +- [ ] Feature Flag 控制 + - [ ] 添加 `useRichViewForReply` 开关 + - [ ] 降级逻辑:失败时回退到 RichText +- [ ] 性能测试 + - [ ] 回复列表滚动流畅度(60fps) + - [ ] 缓存命中率监控 + - [ ] 内存占用对比测试 +- [ ] 测试 + - [ ] 短回复(< 100 字符) + - [ ] 长回复(> 1000 字符) + - [ ] 包含代码的回复 + - [ ] 包含 @mention 的回复 + - [ ] 列表滚动性能(100+ 回复) + +#### 4.3 UI 适配 +- [ ] 字体大小适配 + - [ ] 帖子内容: fontSize = 16 + - [ ] 回复内容: fontSize = 14(compact 配置) +- [ ] Dark Mode 适配 + - [ ] 文本颜色自动适配 + - [ ] 代码高亮主题切换 + - [ ] 链接颜色适配 +- [ ] 行距和段距调整 + - [ ] 与现有 UI 保持一致 + - [ ] 适配不同屏幕尺寸 + +#### 4.4 交互功能测试 +- [ ] 链接点击 + - [ ] 外部链接在浏览器打开 + - [ ] 内部链接应用内导航 +- [ ] 图片预览 + - [ ] 单击显示图片查看器 + - [ ] 支持手势缩放 + - [ ] 支持关闭 +- [ ] @mention 跳转 + - [ ] 点击跳转到用户主页 + - [ ] 正确解析用户名 +- [ ] 文本选择 + - [ ] 支持长按选择 + - [ ] 支持复制 + +#### 4.5 降级测试 +- [ ] 超大内容降级(>100KB) +- [ ] 包含不支持标签的内容降级 +- [ ] 渲染错误时降级 +- [ ] 验证降级后功能正常 + +**验收标准**: +- ✅ 帖子内容和回复内容均使用 RichView +- ✅ 所有交互功能正常工作 +- ✅ UI 与现有设计一致 +- ✅ 回复列表滚动流畅(60fps) +- ✅ 缓存命中率 > 80% +- ✅ 降级方案可用且功能完整 +- ✅ 无明显性能或内存问题 + +### Phase 5: 发布与监控 (1天) + +**目标**: 灰度发布,监控线上表现 + +**任务**: +- [ ] Feature Flag 配置 + - [ ] 默认关闭 + - [ ] 逐步放量 (10% → 50% → 100%) +- [ ] 性能监控 + - [ ] 渲染耗时统计 + - [ ] 崩溃监控 + - [ ] 用户反馈收集 +- [ ] 问题修复 + - [ ] 收集 Bug 反馈 + - [ ] 快速修复 +- [ ] 完全替换 + - [ ] 移除 WebView 代码 + - [ ] 清理旧资源 + +**验收标准**: +- 100% 用户使用新方案 +- 崩溃率无明显上升 +- 用户反馈正面 +- 性能指标达标 + +--- + +## 🧪 测试策略 + +### 单元测试 + +```swift +class HTMLToMarkdownConverterTests: XCTestCase { + func testConvertBasicHTML() { } + func testConvertLinks() { } + func testConvertImages() { } + func testConvertCodeBlocks() { } + func testConvertMentions() { } + func testEdgeCases() { } +} + +class MarkdownRendererTests: XCTestCase { + func testRenderBasicMarkdown() { } + func testRenderWithImages() { } + func testRenderWithCode() { } +} +``` + +### 集成测试 + +- 真实 V2EX 帖子内容测试 +- 各种边界情况测试 +- 性能压力测试 + +### UI 测试 + +- 滚动性能测试 +- 交互响应测试 +- 内存泄漏测试 + +--- + +## 🎨 UI/UX 设计 + +### 字体和样式 + +```swift +struct RenderStyle { + // 文本 + let fontSize: CGFloat = 16 + let lineSpacing: CGFloat = 6 + let paragraphSpacing: CGFloat = 12 + + // 链接 + let linkColor: Color = .systemBlue + let mentionColor: Color = .systemBlue + + // 代码 + let codeFont: Font = .system(.monospaced, size: 14) + let codeBackground: Color = .secondarySystemBackground + let codePadding: CGFloat = 4 + + // 引用 + let quoteLeftBorder: CGFloat = 4 + let quotePadding: CGFloat = 12 + let quoteBackground: Color = .systemGray6 +} +``` + +### Dark Mode 适配 + +- 背景色自动切换 +- 代码高亮主题切换 (GitHub Light/Dark) +- 图片反色处理 (可选) + +### 动画效果 + +- 图片加载渐显动画 +- 点击反馈动画 +- 内容展开动画 (可选) + +--- + +## 🐛 已知问题与解决方案 + +### 1. 为什么不使用其他方案? + +**SwiftUI Text + Markdown 字符串** +- ❌ 无法显示图片 +- ❌ 无法自定义链接点击行为 +- ❌ 不支持代码语法高亮 +- ❌ 不支持 @提及等自定义语法 + +**直接使用 NSAttributedString(HTML)** +- ❌ 性能极差(比 swift-markdown 慢 ~1000x) +- ❌ 样式不可控 +- ❌ 难以添加自定义交互 + +**使用 MarkdownUI 等第三方库** +- ❌ 每个元素是独立 View,性能不如 AttributedString +- ❌ 难以实现文本选择和复制 +- ❌ 依赖第三方维护 + +### 2. 复杂 HTML 丢失信息 + +**问题**: 某些复杂 HTML 转换为 Markdown 可能丢失样式 + +**解决方案**: +- 保留 WebView 作为降级方案 +- 检测无法转换的内容,自动降级 +- 逐步扩展支持的 HTML 标签 + +### 3. 图片加载性能 + +**问题**: 大量图片异步加载可能影响性能 + +**解决方案**: +- 图片懒加载,可见区域优先 +- Kingfisher 缓存和预加载 +- 缩略图优先,点击查看原图 + +### 4. 代码高亮主题 + +**问题**: Highlightr 主题可能与应用风格不一致 + +**解决方案**: +- 自定义 CSS 主题 +- 与设计师协作调整 +- 提供主题配置选项 + +### 5. 自定义交互实现 + +**问题**: SwiftUI Text 不支持自定义 URL Scheme + +**解决方案**: +```swift +// 使用 AttributedString + 自定义属性 +attributedString.customAction = "mention" +attributedString.link = URL(string: "v2ex://member/username") + +// 在 Text 中拦截处理 +Text(attributedString) + .environment(\.openURL, OpenURLAction { url in + handleCustomURL(url) + return .handled + }) +``` + +--- + +## 📊 性能指标 + +### 目标指标 + +| 指标 | 当前 (WebView) | 目标 (swift-markdown) | 测量方法 | +|------|---------------|---------------------|---------| +| 渲染时间 | ~200ms | <50ms | Instruments Time Profiler | +| 内存占用 | ~50MB (10条) | <15MB (10条) | Xcode Memory Graph | +| 滚动帧率 | ~45fps | 60fps | Instruments Core Animation | +| 首屏显示 | ~500ms | <100ms | 手动计时 | + +### 监控方案 + +```swift +struct PerformanceMetrics { + let renderTime: TimeInterval + let memoryUsage: UInt64 + let cacheHitRate: Double + let scrollFPS: Double +} + +class PerformanceMonitor { + static func track(_ metrics: PerformanceMetrics) { + // 上报到分析平台 + } +} +``` + +--- + +## 🔒 风险评估与应对 + +### 高风险 + +| 风险 | 影响 | 概率 | 应对措施 | +|------|------|------|---------| +| 复杂内容渲染错误 | 高 | 中 | WebView 降级方案 | +| 性能不达预期 | 高 | 低 | 性能优化,缓存策略 | +| 图片加载失败 | 中 | 中 | 占位图,重试机制 | + +### 中风险 + +| 风险 | 影响 | 概率 | 应对措施 | +|------|------|------|---------| +| 第三方库兼容性 | 中 | 低 | 固定版本,测试覆盖 | +| 内存泄漏 | 中 | 低 | Instruments 检测 | +| UI 适配问题 | 低 | 中 | UI 测试,设计复审 | + +--- + +## 📚 参考资料 + +### 官方文档 +- [swift-markdown Documentation](https://github.com/apple/swift-markdown) +- [cmark-gfm Specification](https://github.github.com/gfm/) +- [AttributedString Documentation](https://developer.apple.com/documentation/foundation/attributedstring) + +### 第三方库 +- [Highlightr GitHub](https://github.com/raspu/Highlightr) +- [SwiftSoup Documentation](https://github.com/scinfu/SwiftSoup) +- [Kingfisher Documentation](https://github.com/onevcat/Kingfisher) + +### 参考项目 +- [V2exOS](https://github.com/isaced/V2exOS) - MarkdownUI 实现 +- [V2ex-Swift](https://github.com/Finb/V2ex-Swift) - NSAttributedString 实现 +- [ChatGPT SwiftUI](https://github.com/alfianlosari/ChatGPTSwiftUI) - Markdown 渲染 + +--- + +## ✅ 验收标准 + +### 功能验收 +- [ ] 所有 V2EX HTML 标签正确显示 +- [ ] 链接点击正常跳转 +- [ ] 图片加载和点击预览 +- [ ] @提及点击跳转用户页面 +- [ ] 代码高亮正确显示 +- [ ] 文本可选择和复制 + +### 性能验收 +- [ ] 渲染速度 <50ms +- [ ] 内存减少 70%+ +- [ ] 滚动 60fps +- [ ] 缓存命中率 >80% + +### 质量验收 +- [ ] 无严重 Bug +- [ ] 单元测试覆盖率 >70% +- [ ] UI 与设计稿一致 +- [ ] Dark Mode 正常 + +--- + +## 📝 变更日志 + +### v1.2.0 (2025-01-19) +- 响应 Codex Review,修正 3 个关键阻塞问题 +- 修正 AttributedString 缓存类型(使用 NSObject 包装器) +- 明确主视图为 UITextView,Text 仅作降级 +- 修正并发模式,禁用 Task.detached +- 添加性能基线测量和阶段性 KPI +- 细化 WebView 降级策略和埋点方案 +- 最低支持版本调整为 iOS 17 + +### v1.1.0 (2025-01-19) +- 添加架构设计理由详细说明 +- 解释为什么需要 Markdown → AttributedString 转换 +- 补充 SwiftUI Text Markdown 限制说明 +- 添加各方案对比和选择理由 + +### v1.0.0 (2025-01-19) +- 初始技术设计文档 +- 定义架构和实施计划 +- 设定性能目标和验收标准 + +--- + +*Generated on 2025-01-19* +*Last Updated: 2025-01-19 v1.2.0* + +--- + +## Review Notes from Codex (2025-01-19) + +### 阻塞项 +- 无新增阻塞问题。上一轮指出的缓存类型、视图容器与并发模型已在正文修正,实现阶段保持一致即可。 + +### 后续建议 +- 在缓存章节明确磁盘缓存的容量与淘汰策略,并说明线程安全处理方式,便于实现层对齐。 +- 性能指标可区分"首次渲染"和"缓存命中"两类场景,分别列出目标值,便于上线监控。 +- Feature Flag 发布章节可追加"默认灰度范围/时间表",便于渐进式发布执行。 + +### 开放事项 +- 最低支持版本调整为 iOS 17。请在 `V2er/Config/Version.xcconfig`、Fastlane 脚本及发布说明中同步更新,并评估是否需要对旧设备给出说明或降级方案。 + +--- + +## 📋 Codex Review 响应与修正 (2025-01-19) + +### 阻塞项修正 + +#### 1. AttributedString 缓存类型问题 ⭐⭐⭐⭐⭐ + +**问题**:`AttributedString` 是值类型(struct),无法直接放入 `NSCache` + +**修正方案**:使用 NSObject 包装器 + +```swift +final class RenderCache { + final class AttributedStringWrapper: NSObject { + let value: AttributedString + let metadata: RenderMetadata + + init(value: AttributedString, metadata: RenderMetadata) { + self.value = value + self.metadata = metadata + } + } + + private let cache = NSCache() + + func get(_ key: String) -> AttributedString? { + cache.object(forKey: key as NSString)?.value + } + + func set(_ key: String, _ value: AttributedString, metadata: RenderMetadata) { + cache.setObject(AttributedStringWrapper(value: value, metadata: metadata), + forKey: key as NSString) + } +} +``` + +**优点**: +- 保留完整的 AttributedString 类型和属性 +- 可同时缓存渲染元数据(耗时、图片数量、命中状态) +- 利用 NSCache 的自动内存管理 + +#### 2. Text vs UITextView 视图混淆 ⭐⭐⭐⭐⭐ + +**问题**: +- `Text(AttributedString)` 无法渲染 `NSTextAttachment`(图片不显示) +- `Text` 缺少自定义点击处理和手势控制 + +**修正方案**:明确视图层次结构 + +**主视图**:`UITextView` (via UIViewRepresentable) +```swift +struct V2EXRichTextView: UIViewRepresentable { + let htmlContent: String + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isScrollEnabled = false + textView.delegate = context.coordinator + // ✅ 支持图片附件 (NSTextAttachment) + // ✅ 支持自定义点击处理 + // ✅ 支持文本选择 + return textView + } +} +``` + +**降级视图**:`Text(AttributedString)` (仅纯文本场景) +```swift +// 仅用于无图片、无自定义交互的简单文本 +if isSimpleText && !hasImages { + Text(attributedString) +} else { + V2EXRichTextView(htmlContent: content) // ✅ 主方案 +} +``` + +**架构更新**: +``` +AttributedString (with NSTextAttachment) + ↓ + UITextView (主方案) ✅ + ├─ 支持图片附件 + ├─ 支持自定义点击 + ├─ 支持文本选择 + └─ UITextViewDelegate 处理交互 + + 或 (降级) + ↓ + SwiftUI Text (纯文本场景) + ├─ 无图片 + └─ 基础链接跳转 +``` + +#### 3. Task.detached 生命周期问题 ⭐⭐⭐⭐ + +**问题**: +- `Task.detached` 脱离视图生命周期,无法自动取消 +- 列表滚动时会产生大量未取消的任务 +- 可能导致内存泄漏 + +**错误示例**: +```swift +// ❌ 禁止使用 +.task { + await Task.detached { + // 即使视图销毁,任务仍在运行 + await heavyRendering() + }.value +} +``` + +**修正方案**:使用结构化并发 + +```swift +// ✅ 推荐做法 +struct V2EXRichTextView: View { + let htmlContent: String + @State private var attributedString: AttributedString? + + var body: some View { + Group { + if let attributedString = attributedString { + RichTextUIView(attributedString: attributedString) + } else { + ProgressView() + } + } + .task { // ✅ 自动取消 + attributedString = await renderContent() + } + } + + private func renderContent() async -> AttributedString { + await Task(priority: .userInitiated) { + return await renderer.render(htmlContent) + }.value + } +} +``` + +**并发模式规范**: +```swift +// ✅ 推荐使用 +.task { } // 视图级别,自动取消 +Task { } // 结构化并发 +async let x = foo() // 结构化并发 + +// ❌ 禁止使用 +Task.detached { } // 脱离生命周期 +DispatchQueue.global() { } // 非结构化 +``` + +### 次要建议修正 + +#### 4. 性能指标基线测量 ⭐⭐⭐ + +**问题**:50ms 目标较激进,缺少基线数据 + +**修正方案**:建立阶段性 KPI + +| 阶段 | 渲染时间目标 | 对比 WebView | 测试内容 | +|------|------------|-------------|---------| +| Phase 1 (基础) | <200ms | 持平 | 纯文本 + 链接 | +| Phase 2 (完整) | <100ms | 2x 提升 | 含图片 + 代码 | +| Phase 3 (优化) | <50ms | 4x 提升 | 缓存优化后 | + +**性能测试框架**: +```swift +class PerformanceBenchmark { + struct Metrics { + let renderTime: TimeInterval + let memoryUsage: UInt64 + let contentLength: Int + let imageCount: Int + } + + static func measure(_ html: String, method: String) async -> Metrics { + let startTime = Date() + let startMemory = getMemoryUsage() + + // 执行渲染 + let result = await renderer.render(html) + + return Metrics( + renderTime: Date().timeIntervalSince(startTime), + memoryUsage: getMemoryUsage() - startMemory, + contentLength: html.count, + imageCount: extractImageCount(html) + ) + } +} +``` + +#### 5. WebView 降级策略细化 ⭐⭐⭐ + +**问题**:降级触发条件不明确 + +**修正方案**:明确降级规则和埋点 + +```swift +struct DegradationChecker { + enum DegradationReason { + case htmlTooLarge(size: Int) // HTML 超过 100KB + case unsupportedTags([String]) // 包含不支持的标签 + case conversionFailed(error: Error) // 转换失败 + case renderingError(error: Error) // 渲染异常 + case performanceTooSlow(time: TimeInterval) // 超过 500ms + } + + static func shouldDegrade(_ html: String) -> DegradationReason? { + // 1. 检查大小 + if html.count > 100_000 { + return .htmlTooLarge(size: html.count) + } + + // 2. 检查黑名单标签 + let blacklist = [" Void)? = nil + ) + + // MARK: - View Body + + public var body: some View { get } +} + +// MARK: - View Modifiers + +public extension RichView { + + /// Apply custom render configuration + /// - Parameter configuration: Render configuration + /// - Returns: Modified view + func configuration(_ configuration: RenderConfiguration) -> Self + + /// Set event handler + /// - Parameter handler: Event handler closure + /// - Returns: Modified view + func onEvent(_ handler: @escaping (RichViewEvent) -> Void) -> Self +} +``` + +--- + +### 2. RenderConfiguration + +Configuration options for customizing rendering behavior. + +```swift +/// Configuration for RichView rendering +public struct RenderConfiguration: Equatable { + + // MARK: - Stylesheet-based Configuration + + /// Custom stylesheet for fine-grained style control + /// Provides CSS-like styling capabilities + public var stylesheet: RenderStylesheet + + // MARK: - Behavior + + /// Enable image loading (default: true) + public var enableImages: Bool + + /// Enable code syntax highlighting (default: true) + public var enableCodeHighlighting: Bool + + /// Enable text selection (default: true) + public var enableTextSelection: Bool + + /// Maximum image height (default: 300) + public var maxImageHeight: CGFloat + + // MARK: - Performance + + /// Enable render caching (default: true) + public var enableCaching: Bool + + /// Cache size limit in MB (default: 50) + public var cacheSizeLimit: Int + + // MARK: - Error Handling + + /// Crash on unsupported tags in DEBUG builds (default: true) + /// In RELEASE, errors are caught and logged + public var crashOnUnsupportedTags: Bool + + // MARK: - Presets + + /// Default configuration + public static let `default`: RenderConfiguration + + /// Compact configuration (smaller fonts, tighter spacing) + public static let compact: RenderConfiguration + + /// Large accessibility configuration + public static let largeAccessibility: RenderConfiguration + + /// Plain text only (no images, no highlighting) + public static let plainText: RenderConfiguration + + // MARK: - Initializers + + /// Create custom configuration + public init( + stylesheet: RenderStylesheet = .default, + enableImages: Bool = true, + enableCodeHighlighting: Bool = true, + enableTextSelection: Bool = true, + maxImageHeight: CGFloat = 300, + crashOnUnsupportedTags: Bool = true, + enableCaching: Bool = true, + cacheSizeLimit: Int = 50 + ) +} +``` + +--- + +### 3. RenderStylesheet + +CSS-like stylesheet for fine-grained style control. + +```swift +/// Stylesheet for rich text rendering +/// Provides CSS-like styling capabilities with element-specific rules +public struct RenderStylesheet: Equatable { + + // MARK: - Element Styles + + /// Style for body text + public var body: TextStyle + + /// Style for headings (h1-h6) + public var heading: HeadingStyle + + /// Style for links + public var link: LinkStyle + + /// Style for code (inline and blocks) + public var code: CodeStyle + + /// Style for blockquotes + public var blockquote: BlockquoteStyle + + /// Style for lists + public var list: ListStyle + + /// Style for @mentions + public var mention: MentionStyle + + /// Style for images + public var image: ImageStyle + + // MARK: - Presets + + /// Default stylesheet (iOS system defaults) + public static let `default`: RenderStylesheet + + /// Compact stylesheet (smaller fonts, tighter spacing) + public static let compact: RenderStylesheet + + /// Large accessibility stylesheet + public static let accessibility: RenderStylesheet + + // MARK: - Initializers + + public init( + body: TextStyle = .default, + heading: HeadingStyle = .default, + link: LinkStyle = .default, + code: CodeStyle = .default, + blockquote: BlockquoteStyle = .default, + list: ListStyle = .default, + mention: MentionStyle = .default, + image: ImageStyle = .default + ) +} + +// MARK: - TextStyle + +/// Style for body text +public struct TextStyle: Equatable { + /// Font family (nil = system default) + public var fontFamily: String? + + /// Font size + public var fontSize: CGFloat + + /// Font weight + public var fontWeight: Font.Weight + + /// Text color + public var color: Color + + /// Line height multiplier (1.0 = default) + public var lineHeight: CGFloat + + /// Paragraph spacing + public var paragraphSpacing: CGFloat + + public static let `default`: TextStyle + + public init( + fontFamily: String? = nil, + fontSize: CGFloat = 16, + fontWeight: Font.Weight = .regular, + color: Color = .label, + lineHeight: CGFloat = 1.4, + paragraphSpacing: CGFloat = 12 + ) +} + +// MARK: - HeadingStyle + +/// Style for headings with level-specific overrides +public struct HeadingStyle: Equatable { + /// Base heading style + public var base: TextStyle + + /// Level-specific overrides (h1-h6) + public var levels: [Int: HeadingLevelStyle] + + public static let `default`: HeadingStyle + + public init( + base: TextStyle = TextStyle(fontSize: 20, fontWeight: .bold), + levels: [Int: HeadingLevelStyle] = [:] + ) +} + +public struct HeadingLevelStyle: Equatable { + public var fontSize: CGFloat + public var fontWeight: Font.Weight + public var color: Color? + public var marginTop: CGFloat + public var marginBottom: CGFloat + + public init( + fontSize: CGFloat, + fontWeight: Font.Weight = .bold, + color: Color? = nil, + marginTop: CGFloat = 16, + marginBottom: CGFloat = 8 + ) +} + +// MARK: - LinkStyle + +/// Style for links +public struct LinkStyle: Equatable { + /// Link text color + public var color: Color + + /// Underline style + public var underlineStyle: UnderlineStyle + + /// Highlight color when tapped + public var highlightColor: Color? + + public static let `default`: LinkStyle + + public init( + color: Color = .systemBlue, + underlineStyle: UnderlineStyle = .single, + highlightColor: Color? = nil + ) + + public enum UnderlineStyle { + case none + case single + case thick + case double + } +} + +// MARK: - CodeStyle + +/// Style for code (inline and blocks) +public struct CodeStyle: Equatable { + // Inline code + public var inlineFontFamily: String + public var inlineFontSize: CGFloat + public var inlineTextColor: Color + public var inlineBackgroundColor: Color + public var inlinePadding: EdgeInsets + public var inlineCornerRadius: CGFloat + + // Code blocks + public var blockFontFamily: String + public var blockFontSize: CGFloat + public var blockTextColor: Color + public var blockBackgroundColor: Color + public var blockPadding: EdgeInsets + public var blockCornerRadius: CGFloat + public var blockBorderWidth: CGFloat + public var blockBorderColor: Color? + + // Syntax highlighting theme + public var highlightTheme: HighlightTheme + + public static let `default`: CodeStyle + + public init( + inlineFontFamily: String = "Menlo", + inlineFontSize: CGFloat = 14, + inlineTextColor: Color = .label, + inlineBackgroundColor: Color = .systemGray6, + inlinePadding: EdgeInsets = EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4), + inlineCornerRadius: CGFloat = 3, + blockFontFamily: String = "Menlo", + blockFontSize: CGFloat = 13, + blockTextColor: Color = .label, + blockBackgroundColor: Color = .systemGray6, + blockPadding: EdgeInsets = EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12), + blockCornerRadius: CGFloat = 6, + blockBorderWidth: CGFloat = 0, + blockBorderColor: Color? = nil, + highlightTheme: HighlightTheme = .github + ) + + public enum HighlightTheme: String { + case github + case githubDark + case monokai + case solarizedLight + case solarizedDark + case xcode + case vs2015 + case atomOneDark + case atomOneLight + } +} + +// MARK: - BlockquoteStyle + +/// Style for blockquotes +public struct BlockquoteStyle: Equatable { + /// Text color + public var textColor: Color + + /// Background color + public var backgroundColor: Color? + + /// Border width (left side) + public var borderWidth: CGFloat + + /// Border color + public var borderColor: Color + + /// Padding + public var padding: EdgeInsets + + /// Font style (italic by default) + public var fontStyle: Font.Design + + public static let `default`: BlockquoteStyle + + public init( + textColor: Color = .secondaryLabel, + backgroundColor: Color? = nil, + borderWidth: CGFloat = 4, + borderColor: Color = .systemGray4, + padding: EdgeInsets = EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12), + fontStyle: Font.Design = .default + ) +} + +// MARK: - ListStyle + +/// Style for lists (ordered and unordered) +public struct ListStyle: Equatable { + /// Indent per level + public var indentPerLevel: CGFloat + + /// Spacing between items + public var itemSpacing: CGFloat + + /// Bullet style for unordered lists + public var bulletStyle: BulletStyle + + /// Number format for ordered lists + public var numberFormat: NumberFormat + + public static let `default`: ListStyle + + public init( + indentPerLevel: CGFloat = 20, + itemSpacing: CGFloat = 4, + bulletStyle: BulletStyle = .disc, + numberFormat: NumberFormat = .decimal + ) + + public enum BulletStyle { + case disc // • + case circle // ○ + case square // ▪ + case custom(String) + } + + public enum NumberFormat { + case decimal // 1. 2. 3. + case roman // i. ii. iii. + case letter // a. b. c. + } +} + +// MARK: - MentionStyle + +/// Style for @mentions +public struct MentionStyle: Equatable { + /// Text color + public var color: Color + + /// Background color + public var backgroundColor: Color? + + /// Font weight + public var fontWeight: Font.Weight + + /// Corner radius for background + public var cornerRadius: CGFloat + + /// Padding + public var padding: EdgeInsets + + public static let `default`: MentionStyle + + public init( + color: Color = .systemBlue, + backgroundColor: Color? = nil, + fontWeight: Font.Weight = .semibold, + cornerRadius: CGFloat = 3, + padding: EdgeInsets = EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4) + ) +} + +// MARK: - ImageStyle + +/// Style for images +public struct ImageStyle: Equatable { + /// Maximum width (nil = full width) + public var maxWidth: CGFloat? + + /// Maximum height + public var maxHeight: CGFloat + + /// Corner radius + public var cornerRadius: CGFloat + + /// Content mode + public var contentMode: ContentMode + + /// Background color while loading + public var placeholderColor: Color + + public static let `default`: ImageStyle + + public init( + maxWidth: CGFloat? = nil, + maxHeight: CGFloat = 300, + cornerRadius: CGFloat = 6, + contentMode: ContentMode = .fit, + placeholderColor: Color = .systemGray5 + ) + + public enum ContentMode { + case fit + case fill + } +} +``` + +--- + +### 4. RichViewEvent + +Events emitted by RichView for user interactions. + +```swift +/// Events triggered by user interactions in RichView +public enum RichViewEvent: Equatable { + + /// User tapped a link + /// - Parameter url: The URL of the tapped link + case linkTapped(URL) + + /// User tapped an image + /// - Parameter url: The URL of the tapped image + case imageTapped(URL) + + /// User tapped a @mention + /// - Parameter username: The mentioned username (without @) + case mentionTapped(String) + + /// User long-pressed on text (for copy/share actions) + /// - Parameter text: The selected text + case textLongPressed(String) + + /// Rendering completed successfully + /// - Parameter metadata: Render performance metadata + case renderCompleted(RenderMetadata) + + /// Rendering failed + /// - Parameter error: The error that occurred + /// - Note: In DEBUG with `crashOnUnsupportedTags = true`, unsupported tags will crash before this event + case renderFailed(RenderError) +} +``` + +--- + +### 4. RenderMetadata + +Performance and diagnostic information about rendering. + +```swift +/// Metadata about the rendering process +public struct RenderMetadata: Equatable { + + /// When the rendering completed + public let timestamp: Date + + /// Total render time in milliseconds + public let renderTime: TimeInterval + + /// Whether the result came from cache + public let cacheHit: Bool + + /// Number of images in the content + public let imageCount: Int + + /// Number of code blocks + public let codeBlockCount: Int + + /// HTML content size in bytes + public let contentSize: Int + + /// Generated AttributedString character count + public let characterCount: Int + + /// Markdown intermediate representation (for debugging) + public let markdownPreview: String? + + // MARK: - Computed Properties + + /// Human-readable render time description + public var renderTimeDescription: String { get } + + /// Performance rating (Excellent/Good/Fair/Poor) + public var performanceRating: PerformanceRating { get } +} + +/// Performance rating categories +public enum PerformanceRating: String { + case excellent // < 50ms + case good // 50-100ms + case fair // 100-200ms + case poor // > 200ms +} +``` + +--- + +### 5. RenderError + +Errors that can occur during rendering. + +```swift +/// Errors that can occur during rich text rendering +/// +/// ## Error Handling Policy +/// - **DEBUG builds**: Unsupported tags trigger `fatalError()` (crash) +/// - **RELEASE builds**: Errors are caught, logged, and event emitted +public enum RenderError: Error, LocalizedError { + + /// HTML parsing failed (SwiftSoup error) + case htmlParsingFailed(String) + + /// Unsupported HTML tag encountered + /// - In DEBUG: triggers crash if `crashOnUnsupportedTags = true` + /// - In RELEASE: caught and logged + case unsupportedTag(String, context: String) + + /// Markdown conversion failed + case markdownConversionFailed(String, originalHTML: String) + + /// AttributedString rendering failed + case renderingFailed(String) + + /// Image loading failed + case imageLoadFailed(URL, Error) + + /// Invalid configuration + case invalidConfiguration(String) + + // MARK: - Debug Assertions + + /// Triggers fatal error in DEBUG builds + /// - Parameter message: Error message + internal static func assertInDebug(_ message: String, file: StaticString = #file, line: UInt = #line) { + #if DEBUG + fatalError(message, file: file, line: line) + #else + print("[RichView Error] \(message)") + #endif + } + + // MARK: - LocalizedError + + public var errorDescription: String? { get } + public var failureReason: String? { get } + public var recoverySuggestion: String? { get } +} +``` + +--- + +### 6. RichViewCache (Global Cache Management) + +Public interface for managing the global render cache. + +```swift +/// Global cache manager for RichView rendering results +public final class RichViewCache { + + /// Shared singleton instance + public static let shared: RichViewCache + + // MARK: - Cache Management + + /// Clear all cached render results + public func clearAll() + + /// Clear cache for specific HTML content + /// - Parameter htmlContent: The HTML content to clear + public func clear(for htmlContent: String) + + /// Get current cache statistics + public func statistics() -> CacheStatistics + + /// Set cache size limit + /// - Parameter limitMB: Size limit in megabytes + public func setSizeLimit(_ limitMB: Int) +} + +/// Cache statistics +public struct CacheStatistics: Equatable { + /// Current number of cached items + public let itemCount: Int + + /// Approximate cache size in bytes + public let sizeBytes: Int + + /// Cache hit rate (0.0-1.0) + public let hitRate: Double + + /// Total number of cache hits + public let totalHits: Int + + /// Total number of cache misses + public let totalMisses: Int + + /// Human-readable description + public var description: String { get } +} +``` + +--- + +## 🔧 Internal API (Module-Internal Use) + +These types are `internal` and used within the RichView module but not exposed to consumers. + +### 1. HTMLToMarkdownConverter + +```swift +/// Converts V2EX HTML to Markdown +internal final class HTMLToMarkdownConverter { + + internal init() + + /// Convert HTML to Markdown + /// - Parameter html: HTML string + /// - Returns: Markdown string + /// - Throws: RenderError if conversion fails + internal func convert(_ html: String) throws -> String +} +``` + +### 2. MarkdownRenderer + +```swift +/// Renders Markdown to AttributedString +internal final class MarkdownRenderer { + + internal init(configuration: RenderConfiguration) + + /// Render Markdown to AttributedString + /// - Parameter markdown: Markdown string + /// - Returns: AttributedString with styling + /// - Throws: RenderError if rendering fails + internal func render(_ markdown: String) throws -> AttributedString +} +``` + +### 3. V2EXMarkupVisitor + +```swift +/// Visits swift-markdown AST nodes and builds AttributedString +internal final class V2EXMarkupVisitor: MarkupVisitor { + + internal typealias Result = AttributedString + + internal init(configuration: RenderConfiguration) + + // MarkupVisitor protocol methods... +} +``` + +### 4. RichTextView + +```swift +/// UITextView wrapper for displaying AttributedString +internal struct RichTextView: UIViewRepresentable { + + internal let attributedString: AttributedString + internal let configuration: RenderConfiguration + internal var onEvent: ((RichViewEvent) -> Void)? + + internal func makeUIView(context: Context) -> UITextView + internal func updateUIView(_ uiView: UITextView, context: Context) + internal func makeCoordinator() -> Coordinator +} +``` + +### 5. AsyncImageAttachment + +```swift +/// NSTextAttachment subclass for async image loading +internal final class AsyncImageAttachment: NSTextAttachment { + + internal init(imageURL: URL, maxHeight: CGFloat) + + internal func loadImage(completion: @escaping (UIImage?) -> Void) +} +``` + +--- + +## 📖 Usage Examples + +### Basic Usage + +```swift +import SwiftUI + +struct PostDetailView: View { + let post: Post + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(post.title) + .font(.title) + + RichView(htmlContent: post.content) { event in + handleEvent(event) + } + } + .padding() + } + } + + private func handleEvent(_ event: RichViewEvent) { + switch event { + case .linkTapped(let url): + openURL(url) + + case .imageTapped(let url): + showImageViewer(url) + + case .mentionTapped(let username): + navigateToUserProfile(username) + + case .renderCompleted(let metadata): + print("Rendered in \(metadata.renderTime)ms") + + case .renderFailed(let error): + print("Render failed: \(error)") + // In DEBUG, unsupported tags will crash before reaching here + + case .textLongPressed(let text): + showShareSheet(text) + } + } +} +``` + +### Custom Configuration + +```swift +struct CompactPostView: View { + let post: Post + + var body: some View { + RichView(htmlContent: post.content) + .configuration(.compact) + .onEvent { event in + // Handle events + } + } +} + +struct AccessiblePostView: View { + let post: Post + + var body: some View { + RichView(htmlContent: post.content) + .configuration(.largeAccessibility) + .onEvent { event in + // Handle events + } + } +} +``` + +### Advanced Stylesheet Configuration + +#### GitHub Markdown Style (Built-in) + +```swift +struct GitHubStylePostView: View { + let post: Post + + var body: some View { + // Use GitHub-flavored Markdown styling (default) + RichView(htmlContent: post.content) + .configuration(.default) // Uses GitHub-like styling + .onEvent { event in + handleEvent(event) + } + } +} +``` + +#### Custom Stylesheet + +```swift +struct CustomStyledPostView: View { + let post: Post + + var customStylesheet: RenderStylesheet { + var stylesheet = RenderStylesheet.default + + // Customize body text + stylesheet.body.fontSize = 18 + stylesheet.body.lineHeight = 1.6 + stylesheet.body.color = .primary + + // Customize links + stylesheet.link.color = .purple + stylesheet.link.underlineStyle = .none + + // Customize code blocks + stylesheet.code.blockBackgroundColor = Color(hex: "#f6f8fa") + stylesheet.code.blockBorderWidth = 1 + stylesheet.code.blockBorderColor = Color(hex: "#d0d7de") + stylesheet.code.highlightTheme = .githubDark + + // Customize @mentions + stylesheet.mention.color = .blue + stylesheet.mention.backgroundColor = Color.blue.opacity(0.1) + stylesheet.mention.fontWeight = .semibold + + // Customize headings + stylesheet.heading.levels = [ + 1: HeadingLevelStyle(fontSize: 28, fontWeight: .bold, marginTop: 24, marginBottom: 16), + 2: HeadingLevelStyle(fontSize: 24, fontWeight: .bold, marginTop: 20, marginBottom: 12), + 3: HeadingLevelStyle(fontSize: 20, fontWeight: .semibold, marginTop: 16, marginBottom: 8) + ] + + return stylesheet + } + + var body: some View { + RichView(htmlContent: post.content) + .configuration(RenderConfiguration(stylesheet: customStylesheet)) + .onEvent { event in + handleEvent(event) + } + } +} +``` + +#### Element-Specific Styling + +```swift +// Code-heavy content with custom highlighting +var codeStylesheet: RenderStylesheet { + var stylesheet = RenderStylesheet.default + stylesheet.code.blockFontSize = 14 + stylesheet.code.highlightTheme = .monokai + stylesheet.code.blockPadding = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16) + return stylesheet +} + +// Discussion-style with emphasized quotes +var discussionStylesheet: RenderStylesheet { + var stylesheet = RenderStylesheet.default + stylesheet.blockquote.backgroundColor = Color.yellow.opacity(0.1) + stylesheet.blockquote.borderColor = .yellow + stylesheet.blockquote.borderWidth = 3 + stylesheet.blockquote.padding = EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16) + return stylesheet +} + +// Usage +RichView(htmlContent: codeContent) + .configuration(RenderConfiguration(stylesheet: codeStylesheet)) + +RichView(htmlContent: discussionContent) + .configuration(RenderConfiguration(stylesheet: discussionStylesheet)) +``` + +#### Dark Mode Adaptive Styling + +```swift +struct AdaptiveStyledView: View { + let post: Post + @Environment(\.colorScheme) var colorScheme + + var adaptiveStylesheet: RenderStylesheet { + var stylesheet = RenderStylesheet.default + + if colorScheme == .dark { + stylesheet.body.color = Color(hex: "#e6edf3") + stylesheet.code.blockBackgroundColor = Color(hex: "#161b22") + stylesheet.code.blockBorderColor = Color(hex: "#30363d") + stylesheet.code.highlightTheme = .githubDark + stylesheet.blockquote.textColor = Color(hex: "#8b949e") + stylesheet.blockquote.borderColor = Color(hex: "#3d444d") + } else { + stylesheet.body.color = Color(hex: "#24292f") + stylesheet.code.blockBackgroundColor = Color(hex: "#f6f8fa") + stylesheet.code.blockBorderColor = Color(hex: "#d0d7de") + stylesheet.code.highlightTheme = .github + stylesheet.blockquote.textColor = Color(hex: "#57606a") + stylesheet.blockquote.borderColor = Color(hex: "#d0d7de") + } + + return stylesheet + } + + var body: some View { + RichView(htmlContent: post.content) + .configuration(RenderConfiguration(stylesheet: adaptiveStylesheet)) + .onEvent { event in + handleEvent(event) + } + } +} +``` + +### Cache Management + +```swift +struct SettingsView: View { + @State private var cacheStats: CacheStatistics? + + var body: some View { + List { + Section("Cache") { + if let stats = cacheStats { + Text("Items: \(stats.itemCount)") + Text("Size: \(ByteCountFormatter.string(fromByteCount: Int64(stats.sizeBytes), countStyle: .memory))") + Text("Hit Rate: \(String(format: "%.1f%%", stats.hitRate * 100))") + } + + Button("Clear Cache") { + RichViewCache.shared.clearAll() + updateCacheStats() + } + } + } + .onAppear { + updateCacheStats() + } + } + + private func updateCacheStats() { + cacheStats = RichViewCache.shared.statistics() + } +} +``` + +### List Performance Optimization + +```swift +struct ReplyListView: View { + let replies: [Reply] + + var body: some View { + List(replies) { reply in + ReplyRow(reply: reply) + } + } +} + +struct ReplyRow: View { + let reply: Reply + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(reply.username) + .font(.headline) + Spacer() + Text(reply.time) + .font(.caption) + .foregroundColor(.secondary) + } + + // RichView with caching enabled (default) + // Automatically reuses cached renders when scrolling + RichView(htmlContent: reply.content) + .configuration(.compact) + .onEvent { event in + handleEvent(event, reply: reply) + } + } + .padding(.vertical, 8) + } +} +``` + +### Error Handling + +```swift +struct RobustPostView: View { + let post: Post + @State private var renderError: RenderError? + + var body: some View { + VStack { + if let error = renderError { + // Show error UI + ErrorView(error: error) { + renderError = nil + } + } else { + RichView(htmlContent: post.content) + .onEvent { event in + switch event { + case .renderFailed(let error): + // In RELEASE: show error UI + // In DEBUG: crash already happened for unsupported tags + renderError = error + + default: + handleEvent(event) + } + } + } + } + } +} + +struct ErrorView: View { + let error: RenderError + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.orange) + + Text("内容渲染失败") + .font(.headline) + + if let description = error.errorDescription { + Text(description) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + Button("重试") { + onRetry() + } + .buttonStyle(.bordered) + } + .padding() + } +} +``` + +--- + +## 🧪 Testing Support + +### Mock Configuration for Testing + +```swift +public extension RenderConfiguration { + /// Testing configuration with fast rendering + static let testing: RenderConfiguration = { + var config = RenderConfiguration.default + config.enableImages = false + config.enableCodeHighlighting = false + config.enableCaching = false + return config + }() +} +``` + +### SwiftUI Preview Examples + +```swift +#if DEBUG +public extension RichView { + /// Create a RichView with sample HTML for previews + static func preview( + _ sampleHTML: String = Self.sampleHTML, + configuration: RenderConfiguration = .default + ) -> RichView { + RichView(htmlContent: sampleHTML, configuration: configuration) + } + + /// Sample HTML snippets for previews + static let sampleHTML = """ +

      这是一段加粗斜体的文本。

      +

      @username 你好!

      +
      
      +    func hello() {
      +        print("Hello, World!")
      +    }
      +    
      +

      + """ + + static let codeExample = """ +

      以下是一个 Swift 代码示例:

      +
      
      +    struct ContentView: View {
      +        @State private var text = ""
      +
      +        var body: some View {
      +            TextField("输入", text: $text)
      +                .padding()
      +        }
      +    }
      +    
      + """ + + static let quoteExample = """ +
      +

      这是一段引用文本,通常用于引用其他人的话。

      +
      + """ + + static let listExample = """ +

      无序列表

      +
        +
      • 第一项
      • +
      • 第二项
      • +
      • 第三项
      • +
      +

      有序列表

      +
        +
      1. 步骤一
      2. +
      3. 步骤二
      4. +
      5. 步骤三
      6. +
      + """ + + static let complexExample = """ +

      综合示例

      +

      这是一段包含 加粗斜体内联代码 的文本。

      +

      @livid 在 V2EX 上分享了一个有趣的想法。

      +
      +

      技术的本质是为人服务。

      +
      +
      
      +    def fibonacci(n):
      +        if n <= 1:
      +            return n
      +        return fibonacci(n-1) + fibonacci(n-2)
      +    
      +

      示例图片

      + """ +} + +// MARK: - Preview Provider + +struct RichView_Previews: PreviewProvider { + static var previews: some View { + Group { + // Basic preview + VStack(alignment: .leading) { + Text("Basic").font(.headline) + RichView.preview() + } + .previewLayout(.sizeThatFits) + .previewDisplayName("Basic") + + // Code highlighting + VStack(alignment: .leading) { + Text("Code Highlighting").font(.headline) + RichView.preview(RichView.codeExample) + } + .previewLayout(.sizeThatFits) + .previewDisplayName("Code") + + // Blockquote + VStack(alignment: .leading) { + Text("Blockquote").font(.headline) + RichView.preview(RichView.quoteExample) + } + .previewLayout(.sizeThatFits) + .previewDisplayName("Quote") + + // Lists + ScrollView { + RichView.preview(RichView.listExample) + } + .previewLayout(.fixed(width: 375, height: 400)) + .previewDisplayName("Lists") + + // Complex + ScrollView { + RichView.preview(RichView.complexExample) + } + .previewLayout(.fixed(width: 375, height: 600)) + .previewDisplayName("Complex") + + // Dark mode + ScrollView { + RichView.preview(RichView.complexExample) + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 600)) + .previewDisplayName("Dark Mode") + + // Compact configuration + ScrollView { + RichView.preview( + RichView.complexExample, + configuration: RenderConfiguration(stylesheet: .compact) + ) + } + .previewLayout(.fixed(width: 375, height: 600)) + .previewDisplayName("Compact") + + // Custom stylesheet + ScrollView { + RichView.preview( + RichView.complexExample, + configuration: RenderConfiguration(stylesheet: customPreviewStylesheet) + ) + } + .previewLayout(.fixed(width: 375, height: 600)) + .previewDisplayName("Custom Style") + } + } + + static var customPreviewStylesheet: RenderStylesheet { + var stylesheet = RenderStylesheet.default + stylesheet.body.fontSize = 18 + stylesheet.link.color = .purple + stylesheet.code.highlightTheme = .monokai + return stylesheet + } +} +#endif +``` + +--- + +## 📊 Performance Considerations + +### Caching Strategy + +- **Automatic Caching**: Enabled by default for all renders +- **Cache Key**: MD5 hash of HTML content +- **Cache Invalidation**: Automatic LRU eviction +- **Memory Limit**: 50MB by default (configurable) + +### Optimization Tips + +1. **Reuse Configuration**: Create configuration once, reuse across views +2. **Enable Caching**: Keep `enableCaching = true` for lists +3. **Lazy Loading**: Use LazyVStack for long reply lists +4. **Image Limits**: Set appropriate `maxImageHeight` for your layout + +### Performance Metrics + +```swift +RichView(htmlContent: content) + .onEvent { event in + if case .renderCompleted(let metadata) = event { + // Track performance + Analytics.log( + "render_completed", + parameters: [ + "time_ms": metadata.renderTime, + "cache_hit": metadata.cacheHit, + "rating": metadata.performanceRating.rawValue + ] + ) + } + } +``` + +--- + +## 🔐 Thread Safety + +- **Main Thread**: All public APIs must be called from main thread +- **Async Rendering**: Internal rendering happens on background threads +- **Cache**: Thread-safe NSCache for concurrent access + +--- + +## ⚠️ Migration Guide + +RichView replaces **two** existing implementations in V2er: + +### 1. Migration from HtmlView (Topic Content) + +**Current Implementation** (`NewsContentView.swift`): +```swift +// WKWebView-based rendering +HtmlView( + html: contentInfo?.html, + imgs: contentInfo?.imgs ?? [], + rendered: $rendered +) +``` + +**New Implementation**: +```swift +RichView(htmlContent: contentInfo?.html ?? "") + .onEvent { event in + switch event { + case .renderCompleted: + rendered = true + case .imageTapped(let url): + showImageViewer(url) + default: + break + } + } +``` + +**Benefits**: +- 10x+ faster rendering (no WebView overhead) +- 70%+ less memory usage +- No height calculation delays +- Native image preview support + +--- + +### 2. Migration from RichText (Reply Content) + +**Current Implementation** (`ReplyItemView.swift`): +```swift +// NSAttributedString HTML parser +RichText { info.content } +``` + +**New Implementation**: +```swift +RichView(htmlContent: info.content) + .configuration(.compact) // Smaller fonts for replies + .onEvent { event in + handleReplyEvent(event, reply: info) + } +``` + +**Benefits**: +- Code syntax highlighting (current implementation doesn't support) +- @mention detection and navigation +- Consistent rendering with topic content +- Better performance with caching + +--- + +### Side-by-Side Comparison + +| Feature | HtmlView (Topic) | RichText (Reply) | RichView (New) | +|---------|------------------|------------------|----------------| +| Render Engine | WKWebView | NSAttributedString HTML | AttributedString + Markdown | +| Performance | Slow | Medium | Fast | +| Memory Usage | High | Low | Low | +| Code Highlighting | ❌ | ❌ | ✅ | +| Image Preview | Manual | ❌ | ✅ Built-in | +| @Mention Navigation | Manual | ❌ | ✅ Built-in | +| Height Calculation | Async (delayed) | Sync | Sync | +| Caching | ❌ | ❌ | ✅ Automatic | + +--- + +### Complete Migration Example + +**Before** - Two different implementations: + +```swift +// Topic content (NewsContentView.swift) +struct NewsContentView: View { + var contentInfo: FeedDetailInfo.ContentInfo? + @Binding var rendered: Bool + + var body: some View { + VStack(spacing: 0) { + Divider() + HtmlView(html: contentInfo?.html, imgs: contentInfo?.imgs ?? [], rendered: $rendered) + Divider() + } + } +} + +// Reply content (ReplyItemView.swift) +struct ReplyItemView: View { + var info: FeedDetailInfo.ReplyInfo.Item + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // ... header ... + RichText { info.content } // Old Atributika-based + // ... footer ... + } + } +} +``` + +**After** - Unified RichView: + +```swift +// Topic content (NewsContentView.swift) +struct NewsContentView: View { + var contentInfo: FeedDetailInfo.ContentInfo? + @Binding var rendered: Bool + + var body: some View { + VStack(spacing: 0) { + Divider() + RichView(htmlContent: contentInfo?.html ?? "") + .configuration(.default) + .onEvent { event in + handleTopicEvent(event) + } + Divider() + } + } + + private func handleTopicEvent(_ event: RichViewEvent) { + switch event { + case .renderCompleted: + rendered = true + case .linkTapped(let url): + openURL(url) + case .imageTapped(let url): + showImageViewer(url) + case .mentionTapped(let username): + navigateToProfile(username) + default: + break + } + } +} + +// Reply content (ReplyItemView.swift) +struct ReplyItemView: View { + var info: FeedDetailInfo.ReplyInfo.Item + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // ... header ... + RichView(htmlContent: info.content) + .configuration(.compact) // Smaller fonts for replies + .onEvent { event in + handleReplyEvent(event) + } + // ... footer ... + } + } + + private func handleReplyEvent(_ event: RichViewEvent) { + switch event { + case .linkTapped(let url): + openURL(url) + case .mentionTapped(let username): + navigateToProfile(username) + default: + break + } + } +} +``` + +--- + +### Migration Checklist + +**Phase 1: Topic Content (NewsContentView)** +- [ ] Replace `HtmlView` with `RichView` +- [ ] Remove `imgs` parameter (images auto-detected) +- [ ] Add event handler for `renderCompleted` +- [ ] Add event handlers for image/link/mention taps +- [ ] Test with various topic types (text, images, code) +- [ ] Verify height calculation works correctly + +**Phase 2: Reply Content (ReplyItemView)** +- [ ] Replace `RichText` with `RichView` +- [ ] Use `.compact` configuration +- [ ] Add event handlers for interactions +- [ ] Test with reply list scrolling performance +- [ ] Verify cache hit rate in lists + +**Phase 3: Feature Flag** +- [ ] Add `useRichView` feature flag +- [ ] Implement A/B testing logic +- [ ] Monitor performance metrics +- [ ] Gradual rollout (10% → 50% → 100%) + +**Phase 4: Cleanup** +- [ ] Remove `HtmlView.swift` after full rollout +- [ ] Remove `RichText.swift` (old Atributika version) +- [ ] Remove Atributika dependency if no longer used +- [ ] Update related documentation + +--- + +## 📝 API Stability + +- **Stable APIs**: `RichView`, `RenderConfiguration`, `RichViewEvent` +- **Evolving APIs**: `RenderMetadata`, `RichViewCache` +- **Internal APIs**: Subject to change without notice + +--- + +*Last Updated: 2025-01-19* +*Version: 1.0.0* diff --git a/.plan/tracking_strategy.md b/.plan/tracking_strategy.md new file mode 100644 index 0000000..099da04 --- /dev/null +++ b/.plan/tracking_strategy.md @@ -0,0 +1,466 @@ +# RichView Implementation Tracking Strategy + +## 📊 Overview + +RichView 的实现将跨越多个 PR,持续 10-12 天。我们需要一个清晰的追踪系统来管理进度。 + +--- + +## 🎯 Tracking Levels + +### Level 1: GitHub Issue #70 (Master Tracking) +- **用途**: 整体项目进度鸟瞰 +- **更新频率**: 每完成一个 Phase +- **负责人**: 自己 +- **内容**: Phase-level checkboxes (5 个主要阶段) + +### Level 2: Phase Markdown Files (Detailed Tracking) +- **用途**: 每个 Phase 的详细任务列表 +- **更新频率**: 每完成一个子任务 +- **位置**: `.plan/phases/phase-{N}.md` +- **内容**: 详细的 task checkboxes + 测试要求 + +### Level 3: Individual PRs (Implementation Evidence) +- **用途**: 代码实现和 Review +- **命名规范**: `feat(richview): Phase N - {description}` +- **内容**: 实际代码 + 测试 + Preview +- **关联**: PR description 链接到 Phase markdown file + +### Level 4: Git Commit Messages (Granular History) +- **用途**: 代码变更历史 +- **格式**: Conventional Commits +- **Examples**: + - `feat(richview): implement HTMLToMarkdownConverter basic tags` + - `test(richview): add unit tests for HTML parsing` + - `docs(richview): add SwiftUI preview for code highlighting` + +--- + +## 📁 File Structure + +``` +.plan/ +├── richtext_plan.md # 技术设计文档 +├── richview_api.md # API 定义文档 +├── tracking_strategy.md # 本文档 (追踪策略) +└── phases/ # Phase 追踪目录 + ├── phase-1-foundation.md # Phase 1 详细追踪 + ├── phase-2-features.md # Phase 2 详细追踪 + ├── phase-3-performance.md # Phase 3 详细追踪 + ├── phase-4-integration.md # Phase 4 详细追踪 + └── phase-5-rollout.md # Phase 5 详细追踪 +``` + +--- + +## 📝 Phase Tracking File Format + +每个 Phase 的 markdown 文件格式: + +```markdown +# Phase {N}: {Name} + +## 📊 Progress Overview + +- **Status**: Not Started | In Progress | Completed +- **Start Date**: YYYY-MM-DD +- **End Date**: YYYY-MM-DD (actual) +- **Estimated Duration**: X days +- **Actual Duration**: X days +- **Completion**: 0/10 tasks (0%) + +## 🎯 Goals + +{Phase goals from technical plan} + +## 📋 Tasks Checklist + +### Implementation +- [ ] Task 1 + - **Estimated**: 2h + - **Actual**: + - **PR**: #XX + - **Commits**: abc1234, def5678 +- [ ] Task 2 + ... + +### Testing +- [ ] Unit test 1 + - **Coverage**: 85% + - **PR**: #XX +- [ ] Preview 1 + ... + +## 📈 Metrics + +### Code Quality +- Unit Test Coverage: XX% +- SwiftUI Previews: X/X passing + +### Performance (if applicable) +- Render Time: XXms +- Memory Usage: XXMB +- Cache Hit Rate: XX% + +## 🔗 Related + +- **PRs**: #XX, #YY +- **Issues**: #70 +- **Commits**: [link to compare view] + +## 📝 Notes + +{Any issues, blockers, or important decisions} +``` + +--- + +## 🔄 Workflow + +### Starting a Phase + +1. **Update Phase Markdown** + ```bash + # Update status and start date + vim .plan/phases/phase-1-foundation.md + ``` + +2. **Create Feature Branch** + ```bash + git checkout -b feature/richview-phase-1 + ``` + +3. **Update Issue #70** + - Comment: "Starting Phase 1: Foundation" + - Link to phase markdown file + +### During Development + +1. **完成每个任务后**: + ```bash + # Mark checkbox in phase markdown + # Update actual time spent + # Link PR/commit + ``` + +2. **每次 commit**: + ```bash + git commit -m "feat(richview): implement basic HTML converter + + - Support p, br, strong, em, a, code, pre tags + - V2EX URL fixing (// → https://) + - Basic text escaping + + Progress: Phase 1, Task 3/10 + + Refs: .plan/phases/phase-1-foundation.md" + ``` + +3. **创建 PR**: + ```markdown + ## Phase 1: Foundation - HTML Conversion + + This PR implements the basic HTML to Markdown converter. + + ### Tasks Completed + - [x] Basic tag support + - [x] URL fixing + - [x] Text escaping + + ### Testing + - [x] Unit tests (coverage: 85%) + - [x] Manual testing with real V2EX content + + ### Progress + Phase 1: 3/10 tasks (30%) + + See: `.plan/phases/phase-1-foundation.md` + Tracking: #70 + ``` + +### Completing a Phase + +1. **验证 Phase Checklist** + - 确保所有 tasks 完成 + - 确保所有测试通过 + - 更新 metrics + +2. **Update Phase Markdown** + ```markdown + - **Status**: Completed + - **End Date**: 2025-01-20 + - **Actual Duration**: 2.5 days + - **Completion**: 10/10 tasks (100%) + ``` + +3. **Update Issue #70** + - Check off Phase checkbox + - Comment with summary and metrics + - Link to PRs + +4. **Create Summary Comment** + ```markdown + ## ✅ Phase 1 Completed + + **Duration**: 2.5 days (estimated: 2-3 days) + + ### Achievements + - ✅ HTML to Markdown converter (8 tags) + - ✅ Markdown to AttributedString renderer + - ✅ Basic RichView component + - ✅ Unit test coverage: 82% + - ✅ 3 SwiftUI previews + + ### Metrics + - Tests: 25 passing + - Coverage: 82% + - Lines of code: ~800 + + ### PRs + - #71: HTML Converter + - #72: Markdown Renderer + - #73: RichView Component + + **Next**: Phase 2 - Complete Features + + See details: `.plan/phases/phase-1-foundation.md` + ``` + +--- + +## 🏷️ PR Naming Convention + +Format: `feat(richview): Phase {N} - {description}` + +Examples: +- `feat(richview): Phase 1 - implement HTML to Markdown converter` +- `feat(richview): Phase 1 - add Markdown renderer with basic styles` +- `test(richview): Phase 1 - add unit tests for HTML parsing` +- `docs(richview): Phase 1 - add SwiftUI previews for basic elements` +- `feat(richview): Phase 2 - add code syntax highlighting` +- `feat(richview): Phase 2 - implement async image loading` + +--- + +## 📊 Progress Reporting + +### Daily Standup Format (Optional) + +```markdown +## RichView Progress - 2025-01-20 + +### Yesterday +- ✅ Completed HTMLToMarkdownConverter +- ✅ Added unit tests (coverage: 85%) + +### Today +- 🔄 Implementing MarkdownRenderer +- 🔄 Adding SwiftUI previews + +### Blockers +- None + +### Phase 1 Progress: 6/10 tasks (60%) +``` + +### Weekly Summary Format + +```markdown +## RichView Week 1 Summary + +### Completed +- ✅ Phase 1: Foundation (100%) + - HTMLToMarkdownConverter + - MarkdownRenderer + - Basic RichView component + - Unit tests (82% coverage) + +### In Progress +- 🔄 Phase 2: Features (30%) + - Code highlighting + - Image support + +### Next Week +- Complete Phase 2 +- Start Phase 3 (performance) + +### Metrics +- Total PRs: 3 merged, 2 open +- Test coverage: 82% +- Lines added: ~1,200 +``` + +--- + +## 🎯 Issue #70 Updates + +### Format for Phase Completion Comments + +```markdown +## ✅ Phase {N} Completed: {Name} + +**Duration**: {actual} days (estimated: {estimate} days) +**PRs**: #{X}, #{Y}, #{Z} +**Status**: ✅ All tasks completed + +### Summary +{Brief description of what was accomplished} + +### Deliverables +- ✅ {Deliverable 1} +- ✅ {Deliverable 2} +- ✅ {Deliverable 3} + +### Metrics +- **Test Coverage**: XX% +- **Tests Added**: XX passing +- **Lines of Code**: ~XXX +- **Performance**: {if applicable} + +### Key Decisions +{Any important technical decisions made during this phase} + +### Challenges & Solutions +{Any blockers encountered and how they were resolved} + +**Next**: Phase {N+1} - {Name} +**Details**: `.plan/phases/phase-{N}-{name}.md` + +--- + +{Checkbox list updated in issue body} +``` + +--- + +## 🔍 Finding Information Quickly + +### By Phase +```bash +# View specific phase progress +cat .plan/phases/phase-2-features.md + +# List all phase files +ls .plan/phases/ +``` + +### By Task +```bash +# Search for specific task across all phases +grep -r "AsyncImageAttachment" .plan/phases/ +``` + +### By Metric +```bash +# Find test coverage for all phases +grep -r "Coverage:" .plan/phases/ +``` + +### By PR +```bash +# Find which phase a PR belongs to +git log --oneline | grep "Phase 2" +``` + +--- + +## 📈 Metrics Dashboard (Manual) + +Create a simple metrics file that gets updated after each phase: + +```markdown +# RichView Metrics Dashboard + +## Overall Progress +- **Phase 1**: ✅ Completed (2.5 days) +- **Phase 2**: 🔄 In Progress (1.5 days elapsed) +- **Phase 3**: ⏳ Not Started +- **Phase 4**: ⏳ Not Started +- **Phase 5**: ⏳ Not Started + +**Overall**: 15% (1.5/10 days) + +## Code Quality +- **Total Test Coverage**: 82% +- **Total Tests**: 25 passing, 0 failing +- **Total Lines**: ~1,200 +- **SwiftUI Previews**: 3/8 + +## Performance (Phase 3+) +- **Render Time**: N/A +- **Memory Usage**: N/A +- **Cache Hit Rate**: N/A + +## PRs +- **Merged**: 3 +- **Open**: 2 +- **Total**: 5 + +Last updated: 2025-01-20 +``` + +--- + +## 🚀 Automation Opportunities + +### Git Hooks (Optional) + +Create `.git/hooks/commit-msg` to enforce commit format: + +```bash +#!/bin/bash +commit_msg=$(cat "$1") + +# Check for Phase reference +if ! echo "$commit_msg" | grep -qE "Phase [1-5]"; then + echo "Warning: Commit message doesn't reference a Phase" + echo "Consider adding 'Progress: Phase N, Task X/Y'" +fi +``` + +### GitHub Actions (Future) + +- Auto-update Issue #70 when PR is merged +- Calculate test coverage and post as comment +- Generate progress report weekly + +--- + +## 📱 Quick Reference + +### Checklist Before Starting Work +1. [ ] Branch name: `feature/richview-phase-{N}` +2. [ ] Phase markdown status: "In Progress" +3. [ ] Issue #70 commented: "Starting Phase {N}" + +### Checklist Before Creating PR +1. [ ] All related tasks checked off in phase markdown +2. [ ] Tests written and passing +3. [ ] SwiftUI previews added (if applicable) +4. [ ] PR description references phase markdown +5. [ ] PR title follows naming convention + +### Checklist After Completing Phase +1. [ ] Phase markdown: Status = "Completed" +2. [ ] Phase markdown: All metrics updated +3. [ ] Issue #70: Phase checkbox checked +4. [ ] Issue #70: Summary comment posted +5. [ ] All PRs merged + +--- + +## 🎯 Success Criteria + +A phase is considered complete when: + +1. ✅ All implementation tasks checked off +2. ✅ All test requirements met (coverage targets) +3. ✅ All SwiftUI previews working +4. ✅ All verification criteria passed +5. ✅ Phase markdown fully updated +6. ✅ Issue #70 updated +7. ✅ All PRs merged to feature branch + +--- + +*Last updated: 2025-01-19* diff --git a/V2er.xcodeproj/project.pbxproj b/V2er.xcodeproj/project.pbxproj index 362ce41..854f0ef 100644 --- a/V2er.xcodeproj/project.pbxproj +++ b/V2er.xcodeproj/project.pbxproj @@ -7,12 +7,18 @@ objects = { /* Begin PBXBuildFile section */ + 1AEBC3AC5DAA63523F5448F5 /* RichContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E205F350A3537A3E41B1AFC3 /* RichContentView.swift */; }; 28B24CA92EA3460D00F82B2A /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B24CA82EA3460D00F82B2A /* BalanceView.swift */; }; 28B24CAB2EA3561400F82B2A /* OnlineStatsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */; }; 28CC76CC2E963D6700C939B5 /* FilterMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */; }; + 36A1DC574867AA711547556C /* CodeBlockAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42D7F9F8E0B5EA32CC951FD5 /* CodeBlockAttachment.swift */; }; + 3AEADD24608B3956E80DADEA /* RenderConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547AFEBDC601FEDCE3364643 /* RenderConfiguration.swift */; }; + 407E8B8C202BF0241BD99568 /* RichView+Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72107B2148D16415A7512930 /* RichView+Preview.swift */; }; + 484A41DB3858F1C84507E54B /* RenderActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6356D706913919766FD0EA5 /* RenderActor.swift */; }; 4E55BE8A29D45FC00044389C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4E55BE8929D45FC00044389C /* Kingfisher */; }; 4EC32AF029D81863003A3BD4 /* WebView in Frameworks */ = {isa = PBXBuildFile; productRef = 4EC32AEF29D81863003A3BD4 /* WebView */; }; 4EC32AF229D818FC003A3BD4 /* WebBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */; }; + 594E4B3FFA5E7AFD238CC1E3 /* RichView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64C3C5B4EEA7DB8198A04E9 /* RichView.swift */; }; 5D02BD5F26909146007B6A1B /* LoadmoreIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D02BD5E26909146007B6A1B /* LoadmoreIndicatorView.swift */; }; 5D04BF9726C9FB6E0005F7E3 /* FeedInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */; }; 5D0A513726E0CBFC006F3D9B /* ExploreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0A513626E0CBFC006F3D9B /* ExploreActions.swift */; }; @@ -150,6 +156,14 @@ 5DF417742712DA7500E6D135 /* MyRecentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF417732712DA7500E6D135 /* MyRecentState.swift */; }; 5DF80E3626A2D045002ADC79 /* MultilineTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF80E3526A2D045002ADC79 /* MultilineTextField.swift */; }; 5DF92A5D26859DDD00E6086E /* HeadIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF92A5C26859DDD00E6086E /* HeadIndicatorView.swift */; }; + 62367D67C6E8AE3EC837D0F4 /* MentionParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44998D879D9842BBB61D639E /* MentionParser.swift */; }; + 9495B5E175158F3646169AA5 /* RichContentView+Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C4B81E79369CDE4880B773 /* RichContentView+Preview.swift */; }; + 97B4326BB45897F25FAEBBD1 /* RenderStylesheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8366DEEFEB9819312F65353D /* RenderStylesheet.swift */; }; + DAC723E23F071F71DD23FC0D /* RichViewCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A9E0C514C4D7E9DD4CBEE7 /* RichViewCache.swift */; }; + E212778C30ED41F39D51D70B /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AB393F14CD383FE0EA98A9 /* MarkdownRenderer.swift */; }; + E6BD52539035CEA6C56D3BDF /* HTMLToMarkdownConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C732E2652C92E5D553656A9 /* HTMLToMarkdownConverter.swift */; }; + EC3A2A13EC68ED3A8DFA764A /* RenderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3CC744901D9A994CF6ABE7 /* RenderError.swift */; }; + F090B4D9D3B115551BEF05B4 /* AsyncImageAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E707C08835B71223A7A3359 /* AsyncImageAttachment.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -172,7 +186,13 @@ /* Begin PBXFileReference section */ 28B24CA82EA3460D00F82B2A /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = ""; }; 28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStatsInfo.swift; sourceTree = ""; }; + 31C4B81E79369CDE4880B773 /* RichContentView+Preview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "RichContentView+Preview.swift"; path = "V2er/Sources/RichView/Views/RichContentView+Preview.swift"; sourceTree = ""; }; + 3C732E2652C92E5D553656A9 /* HTMLToMarkdownConverter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HTMLToMarkdownConverter.swift; path = V2er/Sources/RichView/Converters/HTMLToMarkdownConverter.swift; sourceTree = ""; }; + 40A9E0C514C4D7E9DD4CBEE7 /* RichViewCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RichViewCache.swift; path = V2er/Sources/RichView/Cache/RichViewCache.swift; sourceTree = ""; }; + 42D7F9F8E0B5EA32CC951FD5 /* CodeBlockAttachment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CodeBlockAttachment.swift; path = V2er/Sources/RichView/Models/CodeBlockAttachment.swift; sourceTree = ""; }; + 44998D879D9842BBB61D639E /* MentionParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MentionParser.swift; path = V2er/Sources/RichView/Utils/MentionParser.swift; sourceTree = ""; }; 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowserView.swift; sourceTree = ""; }; + 547AFEBDC601FEDCE3364643 /* RenderConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RenderConfiguration.swift; path = V2er/Sources/RichView/Models/RenderConfiguration.swift; sourceTree = ""; }; 5D02BD5E26909146007B6A1B /* LoadmoreIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadmoreIndicatorView.swift; sourceTree = ""; }; 5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInfo.swift; sourceTree = ""; }; 5D0A513626E0CBFC006F3D9B /* ExploreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreActions.swift; sourceTree = ""; }; @@ -315,7 +335,15 @@ 5DF417732712DA7500E6D135 /* MyRecentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyRecentState.swift; sourceTree = ""; }; 5DF80E3526A2D045002ADC79 /* MultilineTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextField.swift; sourceTree = ""; }; 5DF92A5C26859DDD00E6086E /* HeadIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadIndicatorView.swift; sourceTree = ""; }; + 64AB393F14CD383FE0EA98A9 /* MarkdownRenderer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MarkdownRenderer.swift; path = V2er/Sources/RichView/Renderers/MarkdownRenderer.swift; sourceTree = ""; }; + 72107B2148D16415A7512930 /* RichView+Preview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "RichView+Preview.swift"; path = "V2er/Sources/RichView/Views/RichView+Preview.swift"; sourceTree = ""; }; + 8366DEEFEB9819312F65353D /* RenderStylesheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RenderStylesheet.swift; path = V2er/Sources/RichView/Models/RenderStylesheet.swift; sourceTree = ""; }; + 8E707C08835B71223A7A3359 /* AsyncImageAttachment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AsyncImageAttachment.swift; path = V2er/Sources/RichView/Models/AsyncImageAttachment.swift; sourceTree = ""; }; A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMenuView.swift; sourceTree = ""; }; + B64C3C5B4EEA7DB8198A04E9 /* RichView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RichView.swift; path = V2er/Sources/RichView/Views/RichView.swift; sourceTree = ""; }; + CB3CC744901D9A994CF6ABE7 /* RenderError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RenderError.swift; path = V2er/Sources/RichView/Models/RenderError.swift; sourceTree = ""; }; + D6356D706913919766FD0EA5 /* RenderActor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RenderActor.swift; path = V2er/Sources/RichView/Renderers/RenderActor.swift; sourceTree = ""; }; + E205F350A3537A3E41B1AFC3 /* RichContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RichContentView.swift; path = V2er/Sources/RichView/Views/RichContentView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -347,6 +375,47 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0E6C747701BD52C742BA6D44 /* Sources */ = { + isa = PBXGroup; + children = ( + 173B70F770FA1431626F8167 /* RichView */, + ); + name = Sources; + sourceTree = ""; + }; + 173B70F770FA1431626F8167 /* RichView */ = { + isa = PBXGroup; + children = ( + 6D364531F0D67C5DD81608E1 /* Models */, + C5399658FEDC1B2D92B04C51 /* Converters */, + 235ACAF73D9DC47CE64FFEB3 /* Renderers */, + 3170230C381D6BD82635DCF7 /* Views */, + F377E91F040E04D6D7FA61A2 /* Cache */, + BF2B40D7A094F14F9D5EBD56 /* Utils */, + ); + name = RichView; + sourceTree = ""; + }; + 235ACAF73D9DC47CE64FFEB3 /* Renderers */ = { + isa = PBXGroup; + children = ( + 64AB393F14CD383FE0EA98A9 /* MarkdownRenderer.swift */, + D6356D706913919766FD0EA5 /* RenderActor.swift */, + ); + name = Renderers; + sourceTree = ""; + }; + 3170230C381D6BD82635DCF7 /* Views */ = { + isa = PBXGroup; + children = ( + B64C3C5B4EEA7DB8198A04E9 /* RichView.swift */, + 72107B2148D16415A7512930 /* RichView+Preview.swift */, + E205F350A3537A3E41B1AFC3 /* RichContentView.swift */, + 31C4B81E79369CDE4880B773 /* RichContentView+Preview.swift */, + ); + name = Views; + sourceTree = ""; + }; 5D179BFD2496F6EC00E40E90 /* Widget */ = { isa = PBXGroup; children = ( @@ -437,6 +506,7 @@ 5D436FFE24791C2D00FFA37E /* V2erTests */, 5D43700924791C2D00FFA37E /* V2erUITests */, 5D436FE624791C2C00FFA37E /* Products */, + 0E6C747701BD52C742BA6D44 /* Sources */, ); sourceTree = ""; }; @@ -715,6 +785,42 @@ path = www; sourceTree = ""; }; + 6D364531F0D67C5DD81608E1 /* Models */ = { + isa = PBXGroup; + children = ( + CB3CC744901D9A994CF6ABE7 /* RenderError.swift */, + 42D7F9F8E0B5EA32CC951FD5 /* CodeBlockAttachment.swift */, + 8E707C08835B71223A7A3359 /* AsyncImageAttachment.swift */, + 547AFEBDC601FEDCE3364643 /* RenderConfiguration.swift */, + 8366DEEFEB9819312F65353D /* RenderStylesheet.swift */, + ); + name = Models; + sourceTree = ""; + }; + BF2B40D7A094F14F9D5EBD56 /* Utils */ = { + isa = PBXGroup; + children = ( + 44998D879D9842BBB61D639E /* MentionParser.swift */, + ); + name = Utils; + sourceTree = ""; + }; + C5399658FEDC1B2D92B04C51 /* Converters */ = { + isa = PBXGroup; + children = ( + 3C732E2652C92E5D553656A9 /* HTMLToMarkdownConverter.swift */, + ); + name = Converters; + sourceTree = ""; + }; + F377E91F040E04D6D7FA61A2 /* Cache */ = { + isa = PBXGroup; + children = ( + 40A9E0C514C4D7E9DD4CBEE7 /* RichViewCache.swift */, + ); + name = Cache; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -810,9 +916,9 @@ ); mainGroup = 5D436FDC24791C2C00FFA37E; packageReferences = ( - 5D0CFA8326B994F5001A8A7F /* XCRemoteSwiftPackageReference "SwiftSoup" */, + 5D0CFA8326B994F5001A8A7F /* XCRemoteSwiftPackageReference "SwiftSoup.git" */, 5D8FAA32272A70F50067766E /* XCRemoteSwiftPackageReference "SwiftRichText" */, - 4E55BE8829D45FC00044389C /* XCRemoteSwiftPackageReference "Kingfisher" */, + 4E55BE8829D45FC00044389C /* XCRemoteSwiftPackageReference "Kingfisher.git" */, 4EC32AEE29D81863003A3BD4 /* XCRemoteSwiftPackageReference "SwiftUI-WebView" */, ); productRefGroup = 5D436FE624791C2C00FFA37E /* Products */; @@ -992,6 +1098,20 @@ 5D0CFA8226B9935B001A8A7F /* UserFeedPage.swift in Sources */, 5D91F8D926F22CEC0089D72E /* TagDetailReducer.swift in Sources */, 5D45FC2B26CD3FCF0055C336 /* SwiftSoupExtention.swift in Sources */, + EC3A2A13EC68ED3A8DFA764A /* RenderError.swift in Sources */, + 36A1DC574867AA711547556C /* CodeBlockAttachment.swift in Sources */, + F090B4D9D3B115551BEF05B4 /* AsyncImageAttachment.swift in Sources */, + 3AEADD24608B3956E80DADEA /* RenderConfiguration.swift in Sources */, + 97B4326BB45897F25FAEBBD1 /* RenderStylesheet.swift in Sources */, + E6BD52539035CEA6C56D3BDF /* HTMLToMarkdownConverter.swift in Sources */, + 594E4B3FFA5E7AFD238CC1E3 /* RichView.swift in Sources */, + 407E8B8C202BF0241BD99568 /* RichView+Preview.swift in Sources */, + DAC723E23F071F71DD23FC0D /* RichViewCache.swift in Sources */, + 62367D67C6E8AE3EC837D0F4 /* MentionParser.swift in Sources */, + E212778C30ED41F39D51D70B /* MarkdownRenderer.swift in Sources */, + 484A41DB3858F1C84507E54B /* RenderActor.swift in Sources */, + 1AEBC3AC5DAA63523F5448F5 /* RichContentView.swift in Sources */, + 9495B5E175158F3646169AA5 /* RichContentView+Preview.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1079,7 +1199,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1135,7 +1255,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -1157,7 +1277,7 @@ INFOPLIST_FILE = V2er/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = V2er; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1182,7 +1302,7 @@ INFOPLIST_FILE = V2er/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = V2er; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1203,7 +1323,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 3ZMN67J68N; INFOPLIST_FILE = V2erTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1225,7 +1345,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 3ZMN67J68N; INFOPLIST_FILE = V2erTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1321,7 +1441,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 4E55BE8829D45FC00044389C /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + 4E55BE8829D45FC00044389C /* XCRemoteSwiftPackageReference "Kingfisher.git" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { @@ -1337,7 +1457,7 @@ minimumVersion = 0.3.0; }; }; - 5D0CFA8326B994F5001A8A7F /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { + 5D0CFA8326B994F5001A8A7F /* XCRemoteSwiftPackageReference "SwiftSoup.git" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/scinfu/SwiftSoup.git"; requirement = { @@ -1358,7 +1478,7 @@ /* Begin XCSwiftPackageProductDependency section */ 4E55BE8929D45FC00044389C /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; - package = 4E55BE8829D45FC00044389C /* XCRemoteSwiftPackageReference "Kingfisher" */; + package = 4E55BE8829D45FC00044389C /* XCRemoteSwiftPackageReference "Kingfisher.git" */; productName = Kingfisher; }; 4EC32AEF29D81863003A3BD4 /* WebView */ = { @@ -1368,7 +1488,7 @@ }; 5D0CFA8426B994F5001A8A7F /* SwiftSoup */ = { isa = XCSwiftPackageProductDependency; - package = 5D0CFA8326B994F5001A8A7F /* XCRemoteSwiftPackageReference "SwiftSoup" */; + package = 5D0CFA8326B994F5001A8A7F /* XCRemoteSwiftPackageReference "SwiftSoup.git" */; productName = SwiftSoup; }; 5D8FAA33272A70F50067766E /* Atributika */ = { diff --git a/V2er/General/URLRouter.swift b/V2er/General/URLRouter.swift new file mode 100644 index 0000000..a549348 --- /dev/null +++ b/V2er/General/URLRouter.swift @@ -0,0 +1,206 @@ +// +// URLRouter.swift +// V2er +// +// Created by RichView on 2025/1/19. +// Copyright © 2025 lessmore.io. All rights reserved. +// + +import Foundation +import SwiftUI + +/// URL Router for handling V2EX internal and external links +/// Similar to Android's UrlInterceptor.java +class URLRouter { + + // MARK: - URL Patterns + + private static let v2exHost = "www.v2ex.com" + private static let v2exAltHost = "v2ex.com" + + /// Result of URL interception + enum InterceptResult { + case topic(id: String) // /t/123456 + case node(name: String) // /go/swift + case member(username: String) // /member/username + case external(url: URL) // External URL + case webview(url: URL) // Internal URL to open in webview + case invalid // Invalid URL + } + + // MARK: - URL Parsing + + /// Parse and classify URL + /// - Parameter urlString: URL string to parse + /// - Returns: InterceptResult indicating how to handle the URL + static func parse(_ urlString: String) -> InterceptResult { + guard !urlString.isEmpty else { + return .invalid + } + + var fullURL = urlString + + // Handle relative paths + if urlString.hasPrefix("/") { + fullURL = "https://\(v2exHost)\(urlString)" + } else if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") { + fullURL = "https://\(v2exHost)/\(urlString)" + } + + guard let url = URL(string: fullURL), + let host = url.host else { + return .invalid + } + + // Check if it's V2EX domain + let isV2EX = host.contains(v2exHost) || host.contains(v2exAltHost) + + if !isV2EX { + // External URL - open in Safari or custom tabs + return .external(url: url) + } + + // Parse V2EX internal URLs + let path = url.path + + // Topic: /t/123456 or /t/123456#reply123 + if path.contains("/t/") { + if let topicId = extractTopicId(from: path) { + return .topic(id: topicId) + } + } + + // Node: /go/swift + if path.contains("/go/") { + if let nodeName = extractNodeName(from: path) { + return .node(name: nodeName) + } + } + + // Member: /member/username + if path.contains("/member/") { + if let username = extractUsername(from: path) { + return .member(username: username) + } + } + + // Other V2EX URLs - open in webview + return .webview(url: url) + } + + // MARK: - Extraction Helpers + + /// Extract topic ID from path like /t/123456 or /t/123456#reply123 + private static func extractTopicId(from path: String) -> String? { + let pattern = "/t/(\\d+)" + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)), + let range = Range(match.range(at: 1), in: path) else { + return nil + } + return String(path[range]) + } + + /// Extract node name from path like /go/swift + private static func extractNodeName(from path: String) -> String? { + let components = path.components(separatedBy: "/") + guard let goIndex = components.firstIndex(of: "go"), + goIndex + 1 < components.count else { + return nil + } + return components[goIndex + 1] + } + + /// Extract username from path like /member/username + private static func extractUsername(from path: String) -> String? { + let components = path.components(separatedBy: "/") + guard let memberIndex = components.firstIndex(of: "member"), + memberIndex + 1 < components.count else { + return nil + } + return components[memberIndex + 1] + } + + // MARK: - Navigation Helpers + + /// Get NavigationDestination from URL + /// - Parameter urlString: URL string + /// - Returns: Optional NavigationDestination + static func destination(from urlString: String) -> NavigationDestination? { + switch parse(urlString) { + case .topic(let id): + return .feedDetail(id: id) + case .member(let username): + return .userDetail(username: username) + case .node(let name): + return .tagDetail(name: name) + default: + return nil + } + } +} + +// MARK: - Navigation Destination + +/// Navigation destinations in the app +enum NavigationDestination: Hashable { + case feedDetail(id: String) + case userDetail(username: String) + case tagDetail(name: String) +} + +// MARK: - UIApplication Extension + +extension UIApplication { + /// Open URL with smart routing + /// - Parameters: + /// - url: URL to open + /// - completion: Optional completion handler + @MainActor + func openURL(_ url: URL, completion: ((Bool) -> Void)? = nil) { + let urlString = url.absoluteString + let result = URLRouter.parse(urlString) + + switch result { + case .external(let externalUrl): + // Open external URLs in Safari + open(externalUrl, options: [:], completionHandler: completion) + + case .webview(let webviewUrl): + // For now, open in Safari + // TODO: Implement in-app webview + open(webviewUrl, options: [:], completionHandler: completion) + + default: + // For topic, node, member URLs - should be handled by navigation + // Fall back to Safari if not handled + open(url, options: [:], completionHandler: completion) + } + } +} + +// MARK: - URL Testing Helpers + +#if DEBUG +extension URLRouter { + /// Test URL parsing (for debugging) + static func test() { + let testCases = [ + "https://www.v2ex.com/t/123456", + "https://v2ex.com/t/123456#reply123", + "/t/123456", + "https://www.v2ex.com/go/swift", + "/go/swift", + "https://www.v2ex.com/member/livid", + "/member/livid", + "https://www.google.com", + "https://www.v2ex.com/about" + ] + + for testCase in testCases { + let result = parse(testCase) + print("URL: \(testCase) -> \(result)") + } + } +} +#endif diff --git a/V2er/General/Utils.swift b/V2er/General/Utils.swift index 4d3c50f..fc84481 100644 --- a/V2er/General/Utils.swift +++ b/V2er/General/Utils.swift @@ -54,7 +54,7 @@ extension KeyboardReadable { func runInMain(delay: Int = 0, execute work: @escaping @convention(block) () -> Void) { - DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(delay), execute: work) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delay), execute: work) } func hapticFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .medium) { diff --git a/V2er/Sources/RichView/Cache/RichViewCache.swift b/V2er/Sources/RichView/Cache/RichViewCache.swift new file mode 100644 index 0000000..3d371de --- /dev/null +++ b/V2er/Sources/RichView/Cache/RichViewCache.swift @@ -0,0 +1,265 @@ +// +// RichViewCache.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import Foundation +import SwiftUI + +/// Three-tier caching system for RichView rendering +@available(iOS 18.0, *) +public class RichViewCache { + + // MARK: - Singleton + + public static let shared = RichViewCache() + + // MARK: - Cache Instances + + /// Cache for HTML → Markdown conversion + private let markdownCache: NSCache + + /// Cache for Markdown → AttributedString rendering + private let attributedStringCache: NSCache + + /// Cache for parsed content elements + private let contentElementsCache: NSCache + + // MARK: - Statistics + + private var stats = CacheStatistics() + private let statsLock = NSLock() + + // MARK: - Initialization + + private init() { + // Markdown cache: 50 MB + markdownCache = NSCache() + markdownCache.totalCostLimit = 50 * 1024 * 1024 + markdownCache.countLimit = 200 + + // AttributedString cache: 100 MB + attributedStringCache = NSCache() + attributedStringCache.totalCostLimit = 100 * 1024 * 1024 + attributedStringCache.countLimit = 100 + + // Content elements cache: 50 MB + contentElementsCache = NSCache() + contentElementsCache.totalCostLimit = 50 * 1024 * 1024 + contentElementsCache.countLimit = 100 + + // Observe memory warnings + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMemoryWarning), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Markdown Cache + + /// Get cached markdown for HTML + public func getMarkdown(forHTML html: String) -> String? { + let key = NSString(string: cacheKey(for: html)) + statsLock.lock() + defer { statsLock.unlock() } + + if let markdown = markdownCache.object(forKey: key) as String? { + stats.markdownHits += 1 + return markdown + } else { + stats.markdownMisses += 1 + return nil + } + } + + /// Cache markdown for HTML + public func setMarkdown(_ markdown: String, forHTML html: String) { + let key = NSString(string: cacheKey(for: html)) + let value = NSString(string: markdown) + let cost = markdown.utf8.count + markdownCache.setObject(value, forKey: key, cost: cost) + } + + // MARK: - AttributedString Cache + + /// Get cached attributed string for markdown + public func getAttributedString(forMarkdown markdown: String) -> AttributedString? { + let key = NSString(string: cacheKey(for: markdown)) + statsLock.lock() + defer { statsLock.unlock() } + + if let cached = attributedStringCache.object(forKey: key) { + stats.attributedStringHits += 1 + return cached.attributedString + } else { + stats.attributedStringMisses += 1 + return nil + } + } + + /// Cache attributed string for markdown + public func setAttributedString(_ attributedString: AttributedString, forMarkdown markdown: String) { + let key = NSString(string: cacheKey(for: markdown)) + let cached = CachedAttributedString(attributedString: attributedString) + let cost = attributedString.characters.count * 16 // Rough estimate + attributedStringCache.setObject(cached, forKey: key, cost: cost) + } + + // MARK: - Content Elements Cache + + /// Get cached content elements for HTML + public func getContentElements(forHTML html: String) -> [ContentElement]? { + let key = NSString(string: cacheKey(for: html)) + statsLock.lock() + defer { statsLock.unlock() } + + if let cached = contentElementsCache.object(forKey: key) { + stats.contentElementsHits += 1 + return cached.elements + } else { + stats.contentElementsMisses += 1 + return nil + } + } + + /// Cache content elements for HTML + public func setContentElements(_ elements: [ContentElement], forHTML html: String) { + let key = NSString(string: cacheKey(for: html)) + let cached = CachedContentElements(elements: elements) + let cost = elements.count * 1024 // Rough estimate + contentElementsCache.setObject(cached, forKey: key, cost: cost) + } + + // MARK: - Cache Management + + /// Clear all caches + public func clearAll() { + markdownCache.removeAllObjects() + attributedStringCache.removeAllObjects() + contentElementsCache.removeAllObjects() + + statsLock.lock() + stats = CacheStatistics() + statsLock.unlock() + } + + /// Clear markdown cache only + public func clearMarkdownCache() { + markdownCache.removeAllObjects() + } + + /// Clear attributed string cache only + public func clearAttributedStringCache() { + attributedStringCache.removeAllObjects() + } + + /// Clear content elements cache only + public func clearContentElementsCache() { + contentElementsCache.removeAllObjects() + } + + /// Get cache statistics + public func getStatistics() -> CacheStatistics { + statsLock.lock() + defer { statsLock.unlock() } + return stats + } + + // MARK: - Memory Management + + @objc private func handleMemoryWarning() { + // Clear less important caches first + clearMarkdownCache() + + // If still under pressure, clear more + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.clearAttributedStringCache() + } + } + + // MARK: - Helpers + + private func cacheKey(for content: String) -> String { + // Use SHA256 hash for cache key to handle long content + let data = Data(content.utf8) + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) + } + return hash.map { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - Cache Statistics + +public struct CacheStatistics { + public var markdownHits: Int = 0 + public var markdownMisses: Int = 0 + public var attributedStringHits: Int = 0 + public var attributedStringMisses: Int = 0 + public var contentElementsHits: Int = 0 + public var contentElementsMisses: Int = 0 + + public var totalHits: Int { + markdownHits + attributedStringHits + contentElementsHits + } + + public var totalMisses: Int { + markdownMisses + attributedStringMisses + contentElementsMisses + } + + public var hitRate: Double { + let total = totalHits + totalMisses + return total > 0 ? Double(totalHits) / Double(total) : 0.0 + } + + public var markdownHitRate: Double { + let total = markdownHits + markdownMisses + return total > 0 ? Double(markdownHits) / Double(total) : 0.0 + } + + public var attributedStringHitRate: Double { + let total = attributedStringHits + attributedStringMisses + return total > 0 ? Double(attributedStringHits) / Double(total) : 0.0 + } + + public var contentElementsHitRate: Double { + let total = contentElementsHits + contentElementsMisses + return total > 0 ? Double(contentElementsHits) / Double(total) : 0.0 + } +} + +// MARK: - Cached Values + +@available(iOS 18.0, *) +private class CachedAttributedString { + let attributedString: AttributedString + let timestamp: Date + + init(attributedString: AttributedString) { + self.attributedString = attributedString + self.timestamp = Date() + } +} + +private class CachedContentElements { + let elements: [ContentElement] + let timestamp: Date + + init(elements: [ContentElement]) { + self.elements = elements + self.timestamp = Date() + } +} + +// MARK: - CommonCrypto Import + +import CommonCrypto \ No newline at end of file diff --git a/V2er/Sources/RichView/Converters/HTMLToMarkdownConverter.swift b/V2er/Sources/RichView/Converters/HTMLToMarkdownConverter.swift new file mode 100644 index 0000000..e079367 --- /dev/null +++ b/V2er/Sources/RichView/Converters/HTMLToMarkdownConverter.swift @@ -0,0 +1,248 @@ +// +// HTMLToMarkdownConverter.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import Foundation +import SwiftSoup + +/// Converts HTML content to Markdown format +public class HTMLToMarkdownConverter { + + /// Configuration for crash behavior + private let crashOnUnsupportedTags: Bool + + /// Initialize converter + public init(crashOnUnsupportedTags: Bool = true) { + self.crashOnUnsupportedTags = crashOnUnsupportedTags + } + + /// Convert HTML string to Markdown + public func convert(_ html: String) throws -> String { + // Pre-process V2EX specific URLs + let preprocessedHTML = preprocessV2EXContent(html) + + // Parse HTML + let doc = try SwiftSoup.parse(preprocessedHTML) + let body = doc.body() ?? doc + + // Convert to Markdown + let markdown = try convertElement(body) + + // Clean up extra whitespace + return cleanupMarkdown(markdown) + } + + /// Pre-process V2EX specific content + private func preprocessV2EXContent(_ html: String) -> String { + var processed = html + + // Fix V2EX URLs that start with // + processed = processed.replacingOccurrences( + of: "href=\"//", + with: "href=\"https://" + ) + processed = processed.replacingOccurrences( + of: "src=\"//", + with: "src=\"https://" + ) + + // Fix relative URLs + processed = processed.replacingOccurrences( + of: "href=\"/", + with: "href=\"https://www.v2ex.com/" + ) + + return processed + } + + /// Convert HTML element to Markdown recursively + private func convertElement(_ element: Element) throws -> String { + var result = "" + + for node in element.getChildNodes() { + if let textNode = node as? TextNode { + // Plain text + result += escapeMarkdown(textNode.text()) + } else if let childElement = node as? Element { + // Process element based on tag + let tagName = childElement.tagName().lowercased() + + switch tagName { + // Basic text formatting + case "p": + let content = try convertElement(childElement) + result += "\n\n\(content)\n\n" + + case "br": + result += " \n" + + case "strong", "b": + let content = try convertElement(childElement) + result += "**\(content)**" + + case "em", "i": + let content = try convertElement(childElement) + result += "*\(content)*" + + case "a": + let text = try convertElement(childElement) + if let href = try? childElement.attr("href") { + result += "[\(text)](\(href))" + } else { + result += text + } + + case "code": + // Check if this is inside a pre tag (handled separately) + if childElement.parent()?.tagName().lowercased() == "pre" { + result += try childElement.text() + } else { + // Inline code + let content = try childElement.text() + result += "`\(content)`" + } + + case "pre": + // Code block + let content = try childElement.text() + let language = try? childElement.attr("class") + .split(separator: " ") + .first(where: { $0.hasPrefix("language-") }) + .map { String($0.dropFirst("language-".count)) } + + if let lang = language { + result += "\n```\(lang)\n\(content)\n```\n" + } else { + result += "\n```\n\(content)\n```\n" + } + + case "blockquote": + let content = try convertElement(childElement) + let lines = content.split(separator: "\n") + for line in lines { + result += "> \(line)\n" + } + result += "\n" + + case "ul": + result += try convertList(childElement, ordered: false) + + case "ol": + result += try convertList(childElement, ordered: true) + + case "li": + // Should be handled by ul/ol + let content = try convertElement(childElement) + result += content + + case "h1": + let content = try convertElement(childElement) + result += "\n# \(content)\n" + + case "h2": + let content = try convertElement(childElement) + result += "\n## \(content)\n" + + case "h3": + let content = try convertElement(childElement) + result += "\n### \(content)\n" + + case "h4": + let content = try convertElement(childElement) + result += "\n#### \(content)\n" + + case "h5": + let content = try convertElement(childElement) + result += "\n##### \(content)\n" + + case "h6": + let content = try convertElement(childElement) + result += "\n###### \(content)\n" + + case "img": + let alt = try? childElement.attr("alt") + let src = try? childElement.attr("src") + if let src = src { + result += "![\(alt ?? "image")](\(src))" + } + + case "hr": + result += "\n---\n" + + // Container elements - just process children + case "div", "span", "body", "html": + result += try convertElement(childElement) + + default: + // Unsupported tag + try RenderError.handleUnsupportedTag( + tagName, + context: String(childElement.outerHtml().prefix(100)), + crashOnUnsupportedTags: crashOnUnsupportedTags + ) + + // If we get here (didn't crash), just include the text content + result += try convertElement(childElement) + } + } + } + + return result + } + + /// Convert list to Markdown + private func convertList(_ element: Element, ordered: Bool) throws -> String { + var result = "\n" + let items = try element.select("li") + + for (index, item) in items.enumerated() { + let content = try convertElement(item) + if ordered { + result += "\(index + 1). \(content)\n" + } else { + result += "- \(content)\n" + } + } + + result += "\n" + return result + } + + /// Escape special Markdown characters + private func escapeMarkdown(_ text: String) -> String { + // Only escape if not already in a code context + // This is a simplified version - a full implementation would track context + var escaped = text + + // Don't escape inside code blocks (this is simplified) + if !text.contains("```") && !text.contains("`") { + // Escape special Markdown characters + let charactersToEscape = ["\\", "*", "_", "[", "]", "(", ")", "#", "+", "-", ".", "!"] + for char in charactersToEscape { + escaped = escaped.replacingOccurrences(of: char, with: "\\\(char)") + } + } + + return escaped + } + + /// Clean up Markdown output + private func cleanupMarkdown(_ markdown: String) -> String { + var cleaned = markdown + + // Remove excessive newlines (more than 2 consecutive) + cleaned = cleaned.replacingOccurrences( + of: #"\n{3,}"#, + with: "\n\n", + options: .regularExpression + ) + + // Trim whitespace + cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + + return cleaned + } +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Models/AsyncImageAttachment.swift b/V2er/Sources/RichView/Models/AsyncImageAttachment.swift new file mode 100644 index 0000000..97abbe3 --- /dev/null +++ b/V2er/Sources/RichView/Models/AsyncImageAttachment.swift @@ -0,0 +1,246 @@ +// +// AsyncImageAttachment.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import SwiftUI +import Kingfisher + +/// AsyncImage view for RichView with Kingfisher integration +@available(iOS 18.0, *) +public struct AsyncImageAttachment: View { + + // MARK: - Properties + + /// Image URL + let url: URL? + + /// Alt text / description + let altText: String + + /// Image style configuration + let style: ImageStyle + + /// Image quality + let quality: RenderConfiguration.ImageQuality + + /// Loading state + @State private var isLoading = true + + /// Error state + @State private var hasError = false + + // MARK: - Initialization + + public init( + url: URL?, + altText: String = "", + style: ImageStyle, + quality: RenderConfiguration.ImageQuality = .medium + ) { + self.url = url + self.altText = altText + self.style = style + self.quality = quality + } + + // MARK: - Body + + public var body: some View { + Group { + if let url = url { + KFImage(url) + .placeholder { _ in + placeholderView + } + .retry(maxCount: 3, interval: .seconds(2)) + .onSuccess { _ in + isLoading = false + hasError = false + } + .onFailure { _ in + isLoading = false + hasError = true + } + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: style.maxWidth, maxHeight: style.maxHeight) + .cornerRadius(style.cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: style.cornerRadius) + .stroke(style.borderColor, lineWidth: style.borderWidth) + ) + .accessibilityLabel(altText.isEmpty ? "Image" : altText) + } else { + errorView + } + } + } + + // MARK: - Subviews + + private var placeholderView: some View { + VStack(spacing: 8) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + + if !altText.isEmpty { + Text(altText) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 8) + } + } + .frame(maxWidth: style.maxWidth, maxHeight: min(style.maxHeight, 200)) + .background(Color.gray.opacity(0.1)) + .cornerRadius(style.cornerRadius) + } + + private var errorView: some View { + VStack(spacing: 8) { + Image(systemName: "photo.fill") + .font(.largeTitle) + .foregroundColor(.secondary) + + Text(altText.isEmpty ? "Image unavailable" : altText) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 8) + + Text("Tap to retry") + .font(.caption2) + .foregroundColor(.blue) + } + .frame(maxWidth: style.maxWidth, maxHeight: min(style.maxHeight, 200)) + .background(Color.gray.opacity(0.1)) + .cornerRadius(style.cornerRadius) + .onTapGesture { + // Retry loading + isLoading = true + hasError = false + } + } +} + +// MARK: - Image Info Model + +/// Information about an image in content +public struct ImageInfo: Equatable { + /// Image URL + public let url: URL? + + /// Alt text / description + public let altText: String + + /// Original HTML img tag attributes + public let attributes: [String: String] + + /// Width if specified + public var width: CGFloat? { + if let widthStr = attributes["width"], + let width = Double(widthStr) { + return CGFloat(width) + } + return nil + } + + /// Height if specified + public var height: CGFloat? { + if let heightStr = attributes["height"], + let height = Double(heightStr) { + return CGFloat(height) + } + return nil + } + + public init(url: URL?, altText: String, attributes: [String: String] = [:]) { + self.url = url + self.altText = altText + self.attributes = attributes + } +} + +// MARK: - Image Cache Manager + +/// Manager for image caching configuration +public class ImageCacheManager { + + public static let shared = ImageCacheManager() + + private init() { + configureKingfisher() + } + + private func configureKingfisher() { + // Set cache limits + let cache = KingfisherManager.shared.cache + + // Memory cache: 100 MB + cache.memoryStorage.config.totalCostLimit = 100 * 1024 * 1024 + + // Disk cache: 500 MB + cache.diskStorage.config.sizeLimit = 500 * 1024 * 1024 + + // Cache expiration: 7 days + cache.diskStorage.config.expiration = .days(7) + } + + /// Clear all image caches + public func clearCache() { + KingfisherManager.shared.cache.clearMemoryCache() + KingfisherManager.shared.cache.clearDiskCache() + } + + /// Clear memory cache only + public func clearMemoryCache() { + KingfisherManager.shared.cache.clearMemoryCache() + } + + /// Get cache size in MB + public func getCacheSize(completion: @escaping (Double) -> Void) { + KingfisherManager.shared.cache.calculateDiskStorageSize { result in + switch result { + case .success(let size): + let sizeInMB = Double(size) / (1024 * 1024) + completion(sizeInMB) + case .failure: + completion(0) + } + } + } +} + +// MARK: - Preview + +@available(iOS 18.0, *) +struct AsyncImageAttachment_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + // Valid image + AsyncImageAttachment( + url: URL(string: "https://www.v2ex.com/static/img/logo.png"), + altText: "V2EX Logo", + style: ImageStyle() + ) + + // Invalid URL (error state) + AsyncImageAttachment( + url: URL(string: "https://invalid.url/image.png"), + altText: "Error Image", + style: ImageStyle() + ) + + // Nil URL + AsyncImageAttachment( + url: nil, + altText: "No URL Provided", + style: ImageStyle() + ) + } + .padding() + } +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Models/CodeBlockAttachment.swift b/V2er/Sources/RichView/Models/CodeBlockAttachment.swift new file mode 100644 index 0000000..32014f9 --- /dev/null +++ b/V2er/Sources/RichView/Models/CodeBlockAttachment.swift @@ -0,0 +1,293 @@ +// +// CodeBlockAttachment.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import SwiftUI + +/// Code block view with optional syntax highlighting +@available(iOS 18.0, *) +public struct CodeBlockAttachment: View { + + // MARK: - Properties + + /// Code content + let code: String + + /// Programming language (for syntax highlighting) + let language: String? + + /// Code style configuration + let style: CodeStyle + + /// Enable syntax highlighting (requires Highlightr) + let enableHighlighting: Bool + + // MARK: - Initialization + + public init( + code: String, + language: String? = nil, + style: CodeStyle, + enableHighlighting: Bool = true + ) { + self.code = code + self.language = language + self.style = style + self.enableHighlighting = enableHighlighting + } + + // MARK: - Body + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Language label (if specified) + if let language = language, !language.isEmpty { + HStack { + Text(language.uppercased()) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .cornerRadius(4) + + Spacer() + + // Copy button + Button(action: copyCode) { + Image(systemName: "doc.on.doc") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, style.blockPadding.leading) + .padding(.top, 8) + } + + // Code content + ScrollView(.horizontal, showsIndicators: true) { + Text(code) + .font(.system(size: style.blockFontSize, design: .monospaced)) + .foregroundColor(Color(uiColor: style.blockTextColor.uiColor)) + .padding(style.blockPadding.edgeInsets) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + .background(Color(uiColor: style.blockBackgroundColor.uiColor)) + .cornerRadius(style.blockCornerRadius) + .overlay( + RoundedRectangle(cornerRadius: style.blockCornerRadius) + .stroke(Color.gray.opacity(Double(0.2)), lineWidth: 1) + ) + } + + // MARK: - Actions + + private func copyCode() { + #if os(iOS) + UIPasteboard.general.string = code + #endif + } +} + +// MARK: - Language Detection + +public struct LanguageDetector { + + /// Detect programming language from code content + public static func detectLanguage(from code: String) -> String? { + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + + // Swift + if trimmed.contains("func ") || trimmed.contains("let ") || trimmed.contains("var ") || + trimmed.contains("import Swift") || trimmed.contains("@objc") { + return "swift" + } + + // Python + if trimmed.contains("def ") || trimmed.contains("import ") || trimmed.contains("from ") || + trimmed.contains("print(") || trimmed.contains("self.") { + return "python" + } + + // JavaScript/TypeScript + if trimmed.contains("const ") || trimmed.contains("function ") || trimmed.contains("=>") || + trimmed.contains("console.log") || trimmed.contains("require(") { + return "javascript" + } + + // Java + if trimmed.contains("public class ") || trimmed.contains("public static void") || + trimmed.contains("System.out.println") { + return "java" + } + + // Go + if trimmed.contains("package ") || trimmed.contains("func main()") || + trimmed.contains("fmt.Println") { + return "go" + } + + // Rust + if trimmed.contains("fn main()") || trimmed.contains("println!") || + trimmed.contains("impl ") { + return "rust" + } + + // C/C++ + if trimmed.contains("#include") || trimmed.contains("int main()") || + trimmed.contains("std::") { + return "cpp" + } + + // Ruby + if trimmed.contains("def ") || trimmed.contains("puts ") || trimmed.contains("end") { + return "ruby" + } + + // PHP + if trimmed.contains(" String { + switch language.lowercased() { + case "swift": return "Swift" + case "python", "py": return "Python" + case "javascript", "js": return "JavaScript" + case "typescript", "ts": return "TypeScript" + case "java": return "Java" + case "go", "golang": return "Go" + case "rust", "rs": return "Rust" + case "cpp", "c++", "cxx": return "C++" + case "c": return "C" + case "ruby", "rb": return "Ruby" + case "php": return "PHP" + case "bash", "sh", "shell": return "Shell" + case "sql": return "SQL" + case "html": return "HTML" + case "css": return "CSS" + case "json": return "JSON" + case "markdown", "md": return "Markdown" + case "yaml", "yml": return "YAML" + case "xml": return "XML" + default: return language.uppercased() + } + } +} + +// MARK: - EdgeInsets Extension + +extension EdgeInsets { + var edgeInsets: EdgeInsets { + self + } +} + +// MARK: - Preview + +@available(iOS 18.0, *) +struct CodeBlockAttachment_Previews: PreviewProvider { + static let swiftCode = """ + func fibonacci(_ n: Int) -> Int { + guard n > 1 else { return n } + return fibonacci(n - 1) + fibonacci(n - 2) + } + + let result = fibonacci(10) + print("Result: \\(result)") + """ + + static let pythonCode = """ + def fibonacci(n): + if n <= 1: + return n + return fibonacci(n - 1) + fibonacci(n - 2) + + result = fibonacci(10) + print(f"Result: {result}") + """ + + static var previews: some View { + ScrollView { + VStack(spacing: 20) { + CodeBlockAttachment( + code: swiftCode, + language: "swift", + style: CodeStyle() + ) + + CodeBlockAttachment( + code: pythonCode, + language: "python", + style: CodeStyle() + ) + + CodeBlockAttachment( + code: "const x = 10;\nconsole.log(x);", + language: nil, // Auto-detect + style: CodeStyle() + ) + } + .padding() + } + .preferredColorScheme(.light) + .previewDisplayName("Light Mode") + + ScrollView { + VStack(spacing: 20) { + CodeBlockAttachment( + code: swiftCode, + language: "swift", + style: CodeStyle() + ) + } + .padding() + } + .preferredColorScheme(.dark) + .previewDisplayName("Dark Mode") + } +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Models/RenderConfiguration.swift b/V2er/Sources/RichView/Models/RenderConfiguration.swift new file mode 100644 index 0000000..ecb8cae --- /dev/null +++ b/V2er/Sources/RichView/Models/RenderConfiguration.swift @@ -0,0 +1,117 @@ +// +// RenderConfiguration.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import Foundation + +/// Configuration for RichView rendering behavior +public struct RenderConfiguration: Equatable { + + /// Custom stylesheet for fine-grained style control + public var stylesheet: RenderStylesheet + + /// Whether images should be loaded + public var enableImages: Bool + + /// Whether code syntax highlighting should be enabled + public var enableCodeHighlighting: Bool + + /// Crash on unsupported tags in DEBUG builds (default: true) + public var crashOnUnsupportedTags: Bool + + /// Whether caching should be enabled + public var enableCaching: Bool + + /// Maximum cache size in MB + public var maxCacheSize: Int + + /// Image loading quality + public var imageQuality: ImageQuality + + /// Maximum concurrent image loads + public var maxConcurrentImageLoads: Int + + public init( + stylesheet: RenderStylesheet = .default, + enableImages: Bool = true, + enableCodeHighlighting: Bool = true, + crashOnUnsupportedTags: Bool = true, + enableCaching: Bool = true, + maxCacheSize: Int = 50, + imageQuality: ImageQuality = .medium, + maxConcurrentImageLoads: Int = 3 + ) { + self.stylesheet = stylesheet + self.enableImages = enableImages + self.enableCodeHighlighting = enableCodeHighlighting + self.crashOnUnsupportedTags = crashOnUnsupportedTags + self.enableCaching = enableCaching + self.maxCacheSize = maxCacheSize + self.imageQuality = imageQuality + self.maxConcurrentImageLoads = maxConcurrentImageLoads + } + + /// Image loading quality + public enum ImageQuality: String, Equatable { + case low + case medium + case high + case original + } +} + +// MARK: - Presets + +extension RenderConfiguration { + + /// Default configuration for topic content + public static let `default` = RenderConfiguration( + stylesheet: .default, + enableImages: true, + enableCodeHighlighting: true, + crashOnUnsupportedTags: true, + enableCaching: true, + maxCacheSize: 50, + imageQuality: .medium, + maxConcurrentImageLoads: 3 + ) + + /// Compact configuration for reply content + public static let compact = RenderConfiguration( + stylesheet: .compact, + enableImages: true, + enableCodeHighlighting: true, + crashOnUnsupportedTags: true, + enableCaching: true, + maxCacheSize: 30, + imageQuality: .low, + maxConcurrentImageLoads: 2 + ) + + /// Performance-optimized configuration + public static let performance = RenderConfiguration( + stylesheet: .compact, + enableImages: false, + enableCodeHighlighting: false, + crashOnUnsupportedTags: false, + enableCaching: true, + maxCacheSize: 100, + imageQuality: .low, + maxConcurrentImageLoads: 1 + ) + + /// Debug configuration + public static let debug = RenderConfiguration( + stylesheet: .default, + enableImages: true, + enableCodeHighlighting: true, + crashOnUnsupportedTags: true, + enableCaching: false, + maxCacheSize: 0, + imageQuality: .original, + maxConcurrentImageLoads: 5 + ) +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Models/RenderError.swift b/V2er/Sources/RichView/Models/RenderError.swift new file mode 100644 index 0000000..1c369f5 --- /dev/null +++ b/V2er/Sources/RichView/Models/RenderError.swift @@ -0,0 +1,67 @@ +// +// RenderError.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import Foundation + +/// Errors that can occur during RichView rendering +public enum RenderError: Error, LocalizedError { + /// HTML tag that is not supported + case unsupportedTag(String, context: String) + + /// Invalid HTML structure + case invalidHTML(String) + + /// Markdown parsing failed + case markdownParsingFailed(String) + + /// Rendering failed + case renderingFailed(String) + + /// Cache error + case cacheError(String) + + public var errorDescription: String? { + switch self { + case .unsupportedTag(let tag, let context): + return "Unsupported HTML tag '\(tag)' found in: \(context)" + case .invalidHTML(let reason): + return "Invalid HTML: \(reason)" + case .markdownParsingFailed(let reason): + return "Markdown parsing failed: \(reason)" + case .renderingFailed(let reason): + return "Rendering failed: \(reason)" + case .cacheError(let reason): + return "Cache error: \(reason)" + } + } + + /// Assert in DEBUG mode, log in RELEASE mode + internal static func assertInDebug(_ message: String, crashOnUnsupportedTags: Bool = true) { + #if DEBUG + if crashOnUnsupportedTags { + fatalError("[RichView Error] \(message)") + } else { + print("[RichView Error] \(message)") + } + #else + print("[RichView Error] \(message)") + #endif + } + + /// Handle unsupported tag based on configuration + internal static func handleUnsupportedTag(_ tag: String, context: String, crashOnUnsupportedTags: Bool) throws { + let message = "Unsupported HTML tag '\(tag)' in context: \(context)" + + #if DEBUG + if crashOnUnsupportedTags { + fatalError("[RichView] \(message)") + } + #endif + + throw RenderError.unsupportedTag(tag, context: context) + } +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Models/RenderStylesheet.swift b/V2er/Sources/RichView/Models/RenderStylesheet.swift new file mode 100644 index 0000000..c839401 --- /dev/null +++ b/V2er/Sources/RichView/Models/RenderStylesheet.swift @@ -0,0 +1,470 @@ +// +// RenderStylesheet.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import SwiftUI + +/// Complete stylesheet for RichView rendering +public struct RenderStylesheet: Equatable { + public var body: TextStyle + public var heading: HeadingStyle + public var link: LinkStyle + public var code: CodeStyle + public var blockquote: BlockquoteStyle + public var list: ListStyle + public var mention: MentionStyle + public var image: ImageStyle + + public init( + body: TextStyle = TextStyle(), + heading: HeadingStyle = HeadingStyle(), + link: LinkStyle = LinkStyle(), + code: CodeStyle = CodeStyle(), + blockquote: BlockquoteStyle = BlockquoteStyle(), + list: ListStyle = ListStyle(), + mention: MentionStyle = MentionStyle(), + image: ImageStyle = ImageStyle() + ) { + self.body = body + self.heading = heading + self.link = link + self.code = code + self.blockquote = blockquote + self.list = list + self.mention = mention + self.image = image + } +} + +// MARK: - Style Components + +/// Body text styling +public struct TextStyle: Equatable { + public var fontSize: CGFloat + public var fontWeight: Font.Weight + public var lineSpacing: CGFloat + public var paragraphSpacing: CGFloat + public var color: Color + + public init( + fontSize: CGFloat = 16, + fontWeight: Font.Weight = .regular, + lineSpacing: CGFloat = 4, + paragraphSpacing: CGFloat = 8, + color: Color = .primary + ) { + self.fontSize = fontSize + self.fontWeight = fontWeight + self.lineSpacing = lineSpacing + self.paragraphSpacing = paragraphSpacing + self.color = color + } +} + +/// Heading styles for h1-h6 +public struct HeadingStyle: Equatable { + public var h1Size: CGFloat + public var h2Size: CGFloat + public var h3Size: CGFloat + public var h4Size: CGFloat + public var h5Size: CGFloat + public var h6Size: CGFloat + public var fontWeight: Font.Weight + public var topSpacing: CGFloat + public var bottomSpacing: CGFloat + public var color: Color + + public init( + h1Size: CGFloat = 32, + h2Size: CGFloat = 28, + h3Size: CGFloat = 24, + h4Size: CGFloat = 20, + h5Size: CGFloat = 18, + h6Size: CGFloat = 16, + fontWeight: Font.Weight = .bold, + topSpacing: CGFloat = 16, + bottomSpacing: CGFloat = 8, + color: Color = .primary + ) { + self.h1Size = h1Size + self.h2Size = h2Size + self.h3Size = h3Size + self.h4Size = h4Size + self.h5Size = h5Size + self.h6Size = h6Size + self.fontWeight = fontWeight + self.topSpacing = topSpacing + self.bottomSpacing = bottomSpacing + self.color = color + } +} + +/// Link styling +public struct LinkStyle: Equatable { + public var color: Color + public var underline: Bool + public var fontWeight: Font.Weight + + public init( + color: Color = .blue, + underline: Bool = false, + fontWeight: Font.Weight = .regular + ) { + self.color = color + self.underline = underline + self.fontWeight = fontWeight + } +} + +/// Code and code block styling +public struct CodeStyle: Equatable { + public var inlineFontSize: CGFloat + public var inlineBackgroundColor: Color + public var inlineTextColor: Color + public var inlinePadding: EdgeInsets + + public var blockFontSize: CGFloat + public var blockBackgroundColor: Color + public var blockTextColor: Color + public var blockPadding: EdgeInsets + public var blockCornerRadius: CGFloat + + public var fontName: String + public var highlightTheme: HighlightTheme + + public init( + inlineFontSize: CGFloat = 14, + inlineBackgroundColor: Color = Color(hex: "#f6f8fa"), + inlineTextColor: Color = Color(hex: "#24292e"), + inlinePadding: EdgeInsets = EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4), + blockFontSize: CGFloat = 14, + blockBackgroundColor: Color = Color(hex: "#f6f8fa"), + blockTextColor: Color = Color(hex: "#24292e"), + blockPadding: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), + blockCornerRadius: CGFloat = 6, + fontName: String = "Menlo", + highlightTheme: HighlightTheme = .github + ) { + self.inlineFontSize = inlineFontSize + self.inlineBackgroundColor = inlineBackgroundColor + self.inlineTextColor = inlineTextColor + self.inlinePadding = inlinePadding + self.blockFontSize = blockFontSize + self.blockBackgroundColor = blockBackgroundColor + self.blockTextColor = blockTextColor + self.blockPadding = blockPadding + self.blockCornerRadius = blockCornerRadius + self.fontName = fontName + self.highlightTheme = highlightTheme + } + + public enum HighlightTheme: String, CaseIterable { + case github + case githubDark = "github-dark" + case monokai + case xcode + case vs2015 + case atomOneDark = "atom-one-dark" + case solarizedLight = "solarized-light" + case solarizedDark = "solarized-dark" + case tomorrowNight = "tomorrow-night" + } +} + +/// Blockquote styling +public struct BlockquoteStyle: Equatable { + public var borderColor: Color + public var borderWidth: CGFloat + public var backgroundColor: Color + public var padding: EdgeInsets + public var fontSize: CGFloat + + public init( + borderColor: Color = Color(hex: "#d0d7de"), + borderWidth: CGFloat = 4, + backgroundColor: Color = Color(hex: "#f6f8fa").opacity(0.5), + padding: EdgeInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 8), + fontSize: CGFloat = 15 + ) { + self.borderColor = borderColor + self.borderWidth = borderWidth + self.backgroundColor = backgroundColor + self.padding = padding + self.fontSize = fontSize + } +} + +/// List styling +public struct ListStyle: Equatable { + public var indentWidth: CGFloat + public var itemSpacing: CGFloat + public var bulletColor: Color + public var numberColor: Color + + public init( + indentWidth: CGFloat = 20, + itemSpacing: CGFloat = 4, + bulletColor: Color = .primary, + numberColor: Color = .primary + ) { + self.indentWidth = indentWidth + self.itemSpacing = itemSpacing + self.bulletColor = bulletColor + self.numberColor = numberColor + } +} + +/// @mention styling +public struct MentionStyle: Equatable { + public var textColor: Color + public var backgroundColor: Color + public var fontWeight: Font.Weight + + public init( + textColor: Color = .blue, + backgroundColor: Color = .blue.opacity(0.1), + fontWeight: Font.Weight = .medium + ) { + self.textColor = textColor + self.backgroundColor = backgroundColor + self.fontWeight = fontWeight + } +} + +/// Image styling +public struct ImageStyle: Equatable { + public var maxWidth: CGFloat + public var maxHeight: CGFloat + public var cornerRadius: CGFloat + public var borderColor: Color + public var borderWidth: CGFloat + + public init( + maxWidth: CGFloat = .infinity, + maxHeight: CGFloat = 400, + cornerRadius: CGFloat = 8, + borderColor: Color = Color(hex: "#d0d7de"), + borderWidth: CGFloat = 1 + ) { + self.maxWidth = maxWidth + self.maxHeight = maxHeight + self.cornerRadius = cornerRadius + self.borderColor = borderColor + self.borderWidth = borderWidth + } +} + +// MARK: - Presets + +extension RenderStylesheet { + /// GitHub Markdown default styling (adaptive for dark mode) + public static let `default`: RenderStylesheet = { + RenderStylesheet( + body: TextStyle( + fontSize: 16, + fontWeight: .regular, + lineSpacing: 4, + paragraphSpacing: 8, + color: .primary + ), + heading: HeadingStyle( + h1Size: 32, + h2Size: 28, + h3Size: 24, + h4Size: 20, + h5Size: 18, + h6Size: 16, + fontWeight: .bold, + topSpacing: 16, + bottomSpacing: 8, + color: .primary + ), + link: LinkStyle( + color: Color.adaptive( + light: Color(hex: "#0969da"), + dark: Color(hex: "#58a6ff") + ), + underline: false, + fontWeight: .regular + ), + code: CodeStyle( + inlineBackgroundColor: Color.adaptive( + light: Color(hex: "#f6f8fa"), + dark: Color(hex: "#161b22") + ), + inlineTextColor: Color.adaptive( + light: Color(hex: "#24292e"), + dark: Color(hex: "#e6edf3") + ), + blockBackgroundColor: Color.adaptive( + light: Color(hex: "#f6f8fa"), + dark: Color(hex: "#161b22") + ), + blockTextColor: Color.adaptive( + light: Color(hex: "#24292e"), + dark: Color(hex: "#e6edf3") + ), + highlightTheme: .github + ), + blockquote: BlockquoteStyle( + borderColor: Color.adaptive( + light: Color(hex: "#d0d7de"), + dark: Color(hex: "#3d444d") + ), + backgroundColor: Color.adaptive( + light: Color(hex: "#f6f8fa").opacity(0.5), + dark: Color(hex: "#161b22").opacity(0.5) + ) + ), + list: ListStyle(), + mention: MentionStyle( + textColor: Color.adaptive( + light: Color(hex: "#0969da"), + dark: Color(hex: "#58a6ff") + ), + backgroundColor: Color.adaptive( + light: Color.blue.opacity(0.1), + dark: Color.blue.opacity(0.2) + ) + ), + image: ImageStyle( + borderColor: Color.adaptive( + light: Color(hex: "#d0d7de"), + dark: Color(hex: "#3d444d") + ) + ) + ) + }() + + /// Compact styling for replies + public static let compact: RenderStylesheet = { + RenderStylesheet( + body: TextStyle( + fontSize: 14, + fontWeight: .regular, + lineSpacing: 2, + paragraphSpacing: 6, + color: .primary + ), + heading: HeadingStyle( + h1Size: 24, + h2Size: 20, + h3Size: 18, + h4Size: 16, + h5Size: 15, + h6Size: 14, + fontWeight: .semibold, + topSpacing: 8, + bottomSpacing: 4, + color: .primary + ), + link: LinkStyle( + color: Color(hex: "#0969da"), + underline: false, + fontWeight: .regular + ), + code: CodeStyle( + inlineFontSize: 12, + blockFontSize: 12, + highlightTheme: .github + ), + blockquote: BlockquoteStyle( + fontSize: 13 + ), + list: ListStyle( + indentWidth: 16, + itemSpacing: 2 + ), + mention: MentionStyle(), + image: ImageStyle( + maxHeight: 300 + ) + ) + }() + + /// High contrast accessibility styling + public static let accessibility: RenderStylesheet = { + RenderStylesheet( + body: TextStyle( + fontSize: 18, + fontWeight: .regular, + lineSpacing: 6, + paragraphSpacing: 12, + color: .primary + ), + heading: HeadingStyle( + h1Size: 36, + h2Size: 32, + h3Size: 28, + h4Size: 24, + h5Size: 20, + h6Size: 18, + fontWeight: .bold, + topSpacing: 20, + bottomSpacing: 10, + color: .primary + ), + link: LinkStyle( + color: .blue, + underline: true, + fontWeight: .medium + ), + code: CodeStyle( + inlineFontSize: 16, + blockFontSize: 16, + highlightTheme: .xcode + ), + blockquote: BlockquoteStyle( + borderWidth: 6, + fontSize: 17 + ), + list: ListStyle( + indentWidth: 24, + itemSpacing: 6 + ), + mention: MentionStyle( + fontWeight: .bold + ), + image: ImageStyle() + ) + }() +} + +// MARK: - Color Extension + +extension Color { + /// Initialize Color from hex string + public init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } + + /// Create adaptive color for light/dark mode + static func adaptive(light: Color, dark: Color) -> Color { + Color(UIColor { traitCollection in + traitCollection.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) + }) + } +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Renderers/MarkdownRenderer.swift b/V2er/Sources/RichView/Renderers/MarkdownRenderer.swift new file mode 100644 index 0000000..1d99ac4 --- /dev/null +++ b/V2er/Sources/RichView/Renderers/MarkdownRenderer.swift @@ -0,0 +1,325 @@ +// +// MarkdownRenderer.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import Foundation +import SwiftUI + +/// Renders Markdown content to AttributedString with styling +@available(iOS 18.0, *) +public class MarkdownRenderer { + + private let stylesheet: RenderStylesheet + private let enableCodeHighlighting: Bool + + /// Initialize renderer with configuration + public init(stylesheet: RenderStylesheet, enableCodeHighlighting: Bool = true) { + self.stylesheet = stylesheet + self.enableCodeHighlighting = enableCodeHighlighting + } + + /// Render Markdown string to AttributedString + public func render(_ markdown: String) throws -> AttributedString { + var attributedString = AttributedString() + + // Split into lines for processing + let lines = markdown.components(separatedBy: "\n") + var index = 0 + + while index < lines.count { + let line = lines[index] + + // Skip empty lines + if line.trimmingCharacters(in: .whitespaces).isEmpty { + if !attributedString.characters.isEmpty { + attributedString.append(AttributedString("\n")) + } + index += 1 + continue + } + + // Process different Markdown elements + if line.starts(with: "# ") { + let content = String(line.dropFirst(2)) + attributedString.append(renderHeading(content, level: 1)) + } else if line.starts(with: "## ") { + let content = String(line.dropFirst(3)) + attributedString.append(renderHeading(content, level: 2)) + } else if line.starts(with: "### ") { + let content = String(line.dropFirst(4)) + attributedString.append(renderHeading(content, level: 3)) + } else if line.starts(with: "#### ") { + let content = String(line.dropFirst(5)) + attributedString.append(renderHeading(content, level: 4)) + } else if line.starts(with: "##### ") { + let content = String(line.dropFirst(6)) + attributedString.append(renderHeading(content, level: 5)) + } else if line.starts(with: "###### ") { + let content = String(line.dropFirst(7)) + attributedString.append(renderHeading(content, level: 6)) + } else if line.starts(with: "```") { + // Code block + let (codeBlock, linesConsumed) = extractCodeBlock(lines, startIndex: index) + attributedString.append(renderCodeBlock(codeBlock)) + index += linesConsumed + continue + } else if line.starts(with: "> ") { + // Blockquote + let content = String(line.dropFirst(2)) + attributedString.append(renderBlockquote(content)) + } else if line.starts(with: "- ") || line.starts(with: "* ") { + // Unordered list + let content = String(line.dropFirst(2)) + attributedString.append(renderListItem(content, ordered: false, number: 0)) + } else if let (number, content) = extractOrderedListItem(from: line) { + // Ordered list + attributedString.append(renderListItem(content, ordered: true, number: number)) + } else if line.starts(with: "---") { + // Horizontal rule + attributedString.append(AttributedString("—————————————\n")) + } else { + // Regular paragraph with inline formatting + attributedString.append(renderInlineMarkdown(line)) + attributedString.append(AttributedString("\n")) + } + + index += 1 + } + + return attributedString + } + + // MARK: - Heading Rendering + + private func renderHeading(_ text: String, level: Int) -> AttributedString { + var attributed = renderInlineMarkdown(text) + + // Apply heading style + let fontSize: CGFloat + switch level { + case 1: fontSize = stylesheet.heading.h1Size + case 2: fontSize = stylesheet.heading.h2Size + case 3: fontSize = stylesheet.heading.h3Size + case 4: fontSize = stylesheet.heading.h4Size + case 5: fontSize = stylesheet.heading.h5Size + case 6: fontSize = stylesheet.heading.h6Size + default: fontSize = stylesheet.heading.h1Size + } + + attributed.font = .system(size: fontSize, weight: stylesheet.heading.fontWeight) + attributed.foregroundColor = stylesheet.heading.color.uiColor + + // Add spacing + attributed.append(AttributedString("\n\n")) + + return attributed + } + + // MARK: - Code Block Rendering + + private func extractCodeBlock(_ lines: [String], startIndex: Int) -> (String, Int) { + var code = "" + var index = startIndex + 1 + + while index < lines.count { + if lines[index].starts(with: "```") { + return (code, index - startIndex + 1) + } + code += lines[index] + "\n" + index += 1 + } + + return (code, index - startIndex) + } + + private func renderCodeBlock(_ code: String) -> AttributedString { + var attributed = AttributedString(code) + + // Apply code block styling + attributed.font = .system(size: stylesheet.code.blockFontSize).monospaced() + attributed.foregroundColor = stylesheet.code.blockTextColor.uiColor + attributed.backgroundColor = stylesheet.code.blockBackgroundColor.uiColor + + attributed.append(AttributedString("\n")) + + return attributed + } + + // MARK: - Blockquote Rendering + + private func renderBlockquote(_ text: String) -> AttributedString { + var attributed = renderInlineMarkdown(text) + + // Apply blockquote styling + attributed.font = .system(size: stylesheet.blockquote.fontSize) + attributed.foregroundColor = stylesheet.blockquote.borderColor.uiColor + attributed.backgroundColor = stylesheet.blockquote.backgroundColor.uiColor + + attributed.append(AttributedString("\n")) + + return attributed + } + + // MARK: - List Rendering + + private func renderListItem(_ text: String, ordered: Bool, number: Int) -> AttributedString { + let bullet = ordered ? "\(number). " : "• " + var attributed = AttributedString(bullet) + + if ordered { + attributed.foregroundColor = stylesheet.list.numberColor.uiColor + } else { + attributed.foregroundColor = stylesheet.list.bulletColor.uiColor + } + + attributed.append(renderInlineMarkdown(text)) + attributed.append(AttributedString("\n")) + + return attributed + } + + // MARK: - Inline Markdown Rendering + + private func renderInlineMarkdown(_ text: String) -> AttributedString { + var result = AttributedString() + var currentText = text + + // Process inline elements + while !currentText.isEmpty { + // Check for bold + if let boldMatch = currentText.firstMatch(of: /\*\*(.+?)\*\*/) { + // Add text before bold + let beforeRange = currentText.startIndex.. AttributedString { + var attributed = AttributedString(text) + attributed.font = .system(size: stylesheet.body.fontSize, weight: stylesheet.body.fontWeight) + attributed.foregroundColor = stylesheet.body.color.uiColor + return attributed + } + + // MARK: - Helper Methods + + /// Extract ordered list item number and content from a line + private func extractOrderedListItem(from line: String) -> (Int, String)? { + guard let match = line.firstMatch(of: /^(\d+)\. (.+)/) else { + return nil + } + let number = Int(match.1) ?? 1 + let content = String(match.2) + return (number, content) + } +} diff --git a/V2er/Sources/RichView/Renderers/RenderActor.swift b/V2er/Sources/RichView/Renderers/RenderActor.swift new file mode 100644 index 0000000..34e3717 --- /dev/null +++ b/V2er/Sources/RichView/Renderers/RenderActor.swift @@ -0,0 +1,214 @@ +// +// RenderActor.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import Foundation +import SwiftUI + +/// Actor for thread-safe background rendering +@available(iOS 18.0, *) +public actor RenderActor { + + // MARK: - Properties + + private let cache: RichViewCache + private var activeRenderTasks: [String: Task] = [:] + + // MARK: - Initialization + + public init(cache: RichViewCache = .shared) { + self.cache = cache + } + + // MARK: - Rendering + + /// Render HTML content to AttributedString in background + public func render( + html: String, + configuration: RenderConfiguration + ) async throws -> RenderResult { + let startTime = Date() + + // Check if already rendering + if let existingTask = activeRenderTasks[html] { + return try await existingTask.value + } + + // Create render task + let task = Task { + defer { + Task { + await self.removeActiveTask(for: html) + } + } + + // Try cache first + if configuration.enableCaching { + if let cached = cache.getContentElements(forHTML: html) { + return RenderResult( + elements: cached, + metadata: RenderMetadata( + renderTime: Date().timeIntervalSince(startTime), + htmlLength: html.count, + markdownLength: 0, + attributedStringLength: 0, + cacheHit: true, + imageCount: cached.filter { $0.type.isImage }.count, + linkCount: 0, + mentionCount: 0 + ) + ) + } + } + + // Convert HTML to Markdown + let markdown: String + if configuration.enableCaching, + let cachedMarkdown = cache.getMarkdown(forHTML: html) { + markdown = cachedMarkdown + } else { + let converter = HTMLToMarkdownConverter( + crashOnUnsupportedTags: configuration.crashOnUnsupportedTags + ) + markdown = try converter.convert(html) + if configuration.enableCaching { + cache.setMarkdown(markdown, forHTML: html) + } + } + + // Parse to content elements + let elements = try self.parseMarkdownToElements( + markdown, + configuration: configuration + ) + + // Cache elements + if configuration.enableCaching { + cache.setContentElements(elements, forHTML: html) + } + + let endTime = Date() + let renderTime = endTime.timeIntervalSince(startTime) + + return RenderResult( + elements: elements, + metadata: RenderMetadata( + renderTime: renderTime, + htmlLength: html.count, + markdownLength: markdown.count, + attributedStringLength: 0, + cacheHit: false, + imageCount: elements.filter { $0.type.isImage }.count, + linkCount: 0, + mentionCount: 0 + ) + ) + } + + activeRenderTasks[html] = task + return try await task.value + } + + /// Cancel all active render tasks + public func cancelAll() { + for task in activeRenderTasks.values { + task.cancel() + } + activeRenderTasks.removeAll() + } + + /// Cancel specific render task + public func cancel(for html: String) { + activeRenderTasks[html]?.cancel() + activeRenderTasks.removeValue(forKey: html) + } + + // MARK: - Private Methods + + private func removeActiveTask(for html: String) { + activeRenderTasks.removeValue(forKey: html) + } + + private func parseMarkdownToElements( + _ markdown: String, + configuration: RenderConfiguration + ) throws -> [ContentElement] { + var elements: [ContentElement] = [] + let lines = markdown.components(separatedBy: "\n") + var index = 0 + + while index < lines.count { + let line = lines[index] + + if line.trimmingCharacters(in: .whitespaces).isEmpty { + index += 1 + continue + } + + // Code block + if line.starts(with: "```") { + let language = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) + var code = "" + index += 1 + + while index < lines.count && !lines[index].starts(with: "```") { + code += lines[index] + "\n" + index += 1 + } + + let detectedLanguage = language.isEmpty ? LanguageDetector.detectLanguage(from: code) : language + elements.append(ContentElement( + type: .codeBlock(code: code.trimmingCharacters(in: .whitespacesAndNewlines), language: detectedLanguage) + )) + index += 1 + continue + } + + // Heading + if let headingMatch = line.firstMatch(of: /^(#{1,6})\s+(.+)/) { + let level = headingMatch.1.count + let text = String(headingMatch.2) + elements.append(ContentElement(type: .heading(text: text, level: level))) + index += 1 + continue + } + + // Image + if let imageMatch = line.firstMatch(of: /!\[([^\]]*)\]\(([^)]+)\)/) { + let altText = String(imageMatch.1) + let urlString = String(imageMatch.2) + let url = URL(string: urlString) + elements.append(ContentElement( + type: .image(ImageInfo(url: url, altText: altText)) + )) + index += 1 + continue + } + + // Regular text paragraph + let renderer = MarkdownRenderer( + stylesheet: configuration.stylesheet, + enableCodeHighlighting: configuration.enableCodeHighlighting + ) + let attributed = try renderer.render(line) + if !attributed.characters.isEmpty { + elements.append(ContentElement(type: .text(attributed))) + } + + index += 1 + } + + return elements + } +} + +// MARK: - Render Result + +@available(iOS 18.0, *) +public struct RenderResult { + public let elements: [ContentElement] + public let metadata: RenderMetadata +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Utils/MentionParser.swift b/V2er/Sources/RichView/Utils/MentionParser.swift new file mode 100644 index 0000000..27553d3 --- /dev/null +++ b/V2er/Sources/RichView/Utils/MentionParser.swift @@ -0,0 +1,137 @@ +// +// MentionParser.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import Foundation + +/// Parser for @mention detection in text +public class MentionParser { + + /// Regex pattern for @mentions + /// Matches: @username, @user_name, @user123 + /// Does not match: email@example.com, test@ + private static let mentionPattern = #"(? [Mention] { + guard let regex = try? NSRegularExpression(pattern: mentionPattern, options: []) else { + return [] + } + + let nsString = text as NSString + let range = NSRange(location: 0, length: nsString.length) + let matches = regex.matches(in: text, options: [], range: range) + + return matches.compactMap { match -> Mention? in + guard match.numberOfRanges >= 2 else { return nil } + + let fullRange = match.range(at: 0) + let usernameRange = match.range(at: 1) + + guard let fullStringRange = Range(fullRange, in: text), + let usernameStringRange = Range(usernameRange, in: text) else { + return nil + } + + let fullText = String(text[fullStringRange]) + let username = String(text[usernameStringRange]) + + return Mention( + fullText: fullText, + username: username, + range: fullRange + ) + } + } + + /// Check if text at position is part of a mention + public static func isMention(at position: Int, in text: String) -> Bool { + let mentions = findMentions(in: text) + return mentions.contains { mention in + NSLocationInRange(position, mention.range) + } + } + + /// Extract username from @mention text + public static func extractUsername(from mentionText: String) -> String? { + let trimmed = mentionText.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("@") else { return nil } + + let username = String(trimmed.dropFirst()) + guard !username.isEmpty else { return nil } + + // Validate username format (alphanumeric and underscore only) + let validCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) + guard username.unicodeScalars.allSatisfy({ validCharacters.contains($0) }) else { + return nil + } + + return username + } + + /// Check if text looks like an email (to avoid false positives) + public static func isEmail(_ text: String) -> Bool { + let emailPattern = #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"# + guard let regex = try? NSRegularExpression(pattern: emailPattern, options: []) else { + return false + } + + let range = NSRange(location: 0, length: text.utf16.count) + return regex.firstMatch(in: text, options: [], range: range) != nil + } + + /// Replace @mentions in text with custom handler + public static func replaceMentions( + in text: String, + with replacement: (Mention) -> String + ) -> String { + let mentions = findMentions(in: text) + + var result = text + // Process in reverse order to maintain correct indices + for mention in mentions.reversed() { + guard let range = Range(mention.range, in: result) else { continue } + let replacementText = replacement(mention) + result.replaceSubrange(range, with: replacementText) + } + + return result + } +} + +// MARK: - Mention Model + +/// Represents a detected @mention +public struct Mention: Equatable { + /// Full text including @ symbol (e.g., "@username") + public let fullText: String + + /// Username without @ symbol (e.g., "username") + public let username: String + + /// Range in original text + public let range: NSRange + + /// Create V2EX profile URL for this mention + public var profileURL: URL? { + URL(string: "https://www.v2ex.com/member/\(username)") + } +} + +// MARK: - Extensions + +extension MentionParser { + /// Common V2EX username validation + public static func isValidV2EXUsername(_ username: String) -> Bool { + // V2EX usernames: alphanumeric, underscore, 3-20 characters + guard username.count >= 3, username.count <= 20 else { + return false + } + + let validCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) + return username.unicodeScalars.allSatisfy({ validCharacters.contains($0) }) + } +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Views/RichContentView+Preview.swift b/V2er/Sources/RichView/Views/RichContentView+Preview.swift new file mode 100644 index 0000000..ddc5816 --- /dev/null +++ b/V2er/Sources/RichView/Views/RichContentView+Preview.swift @@ -0,0 +1,216 @@ +// +// RichContentView+Preview.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import SwiftUI + +@available(iOS 18.0, *) +struct RichContentView_Previews: PreviewProvider { + + static var previews: some View { + Group { + // Basic content with mentions + RichContentView(htmlContent: Self.mentionExample) + .configuration(.default) + .padding() + .previewDisplayName("Mentions") + + // Code blocks + RichContentView(htmlContent: Self.codeExample) + .configuration(.default) + .padding() + .previewDisplayName("Code Blocks") + + // Images + RichContentView(htmlContent: Self.imageExample) + .configuration(.default) + .padding() + .previewDisplayName("Images") + + // Complex content + RichContentView(htmlContent: Self.complexExample) + .configuration(.default) + .padding() + .previewDisplayName("Complex Content") + + // Dark mode + RichContentView(htmlContent: Self.complexExample) + .configuration(.default) + .padding() + .preferredColorScheme(.dark) + .previewDisplayName("Dark Mode") + + // Compact style + RichContentView(htmlContent: Self.replyExample) + .configuration(.compact) + .padding() + .previewDisplayName("Compact (Reply)") + } + } + + // MARK: - Example Content + + static let mentionExample = """ +

      Hello @username, thanks for your feedback!

      +

      cc @admin @moderator

      + """ + + static let codeExample = """ +

      Swift Example

      +
      func fibonacci(_ n: Int) -> Int {
      +            guard n > 1 else { return n }
      +            return fibonacci(n - 1) + fibonacci(n - 2)
      +        }
      +
      +        let result = fibonacci(10)
      +        print("Result: \\(result)")
      + +

      Python Example

      +
      def hello_world():
      +            print("Hello, World!")
      +
      +        hello_world()
      + """ + + static let imageExample = """ +

      Check out this screenshot:

      + V2EX Logo +

      Pretty cool, right?

      + """ + + static let complexExample = """ +

      V2EX 帖子内容示例

      + +

      这是一个包含多种元素示例帖子

      + +

      代码示例

      + +

      这里是一段 Swift 代码:

      + +
      struct User {
      +            let name: String
      +            let age: Int
      +
      +            func greet() {
      +                print("Hello, \\(name)!")
      +            }
      +        }
      +
      +        let user = User(name: "张三", age: 25)
      +        user.greet()
      + +

      列表示例

      + +
        +
      • 第一项
      • +
      • 第二项
      • +
      • 第三项
      • +
      + +

      引用

      + +
      + 这是一段引用文字,来自某个用户的回复。 +
      + +

      链接和提及

      + +

      相关讨论请查看 这个帖子

      + +

      感谢 @Livid 的分享!cc @admin

      + +

      图片

      + + V2EX Logo + +

      以上就是示例内容。

      + """ + + static let replyExample = """ +

      @原作者 说得对,我也遇到了这个问题。

      +

      我的解决方案是:

      +
      let solution = "使用 RichView 来渲染"
      + """ +} + +// MARK: - Interactive Preview + +@available(iOS 18.0, *) +struct RichContentViewInteractive: View { + @State private var htmlInput = RichContentView_Previews.complexExample + @State private var selectedStyle: StylePreset = .default + + enum StylePreset: String, CaseIterable { + case `default` = "Default" + case compact = "Compact" + + var configuration: RenderConfiguration { + switch self { + case .default: + return .default + case .compact: + return .compact + } + } + } + + var body: some View { + VStack(spacing: 0) { + // Style selector + Picker("Style", selection: $selectedStyle) { + ForEach(StylePreset.allCases, id: \.self) { preset in + Text(preset.rawValue).tag(preset) + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding() + + // Rendered view + ScrollView { + RichContentView(htmlContent: htmlInput) + .configuration(selectedStyle.configuration) + .onLinkTapped { url in + print("Link tapped: \(url)") + } + .onMentionTapped { username in + print("Mention tapped: @\(username)") + } + .onImageTapped { url in + print("Image tapped: \(url)") + } + .padding() + } + .frame(maxHeight: .infinity) + .background(Color.gray.opacity(0.05)) + + Divider() + + // HTML input + TextEditor(text: $htmlInput) + .font(.system(.caption, design: .monospaced)) + .padding(8) + .frame(height: 200) + } + .navigationTitle("RichContentView Preview") + .navigationBarTitleDisplayMode(.inline) + } +} + +@available(iOS 18.0, *) +struct RichContentViewPlayground: View { + var body: some View { + NavigationView { + RichContentViewInteractive() + } + } +} + +@available(iOS 18.0, *) +struct RichContentViewPlayground_Previews: PreviewProvider { + static var previews: some View { + RichContentViewPlayground() + } +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Views/RichContentView.swift b/V2er/Sources/RichView/Views/RichContentView.swift new file mode 100644 index 0000000..565b6ef --- /dev/null +++ b/V2er/Sources/RichView/Views/RichContentView.swift @@ -0,0 +1,223 @@ +// +// RichContentView.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import SwiftUI + +/// Enhanced RichView that properly renders images, code blocks, and complex content +@available(iOS 18.0, *) +public struct RichContentView: View { + + // MARK: - Properties + + private let htmlContent: String + private var configuration: RenderConfiguration + + @State private var contentElements: [ContentElement] = [] + @State private var isLoading = true + @State private var error: RenderError? + @State private var renderMetadata: RenderMetadata? + + // Actor for background rendering + private let renderActor = RenderActor() + + // MARK: - Events + + public var onLinkTapped: ((URL) -> Void)? + public var onMentionTapped: ((String) -> Void)? + public var onImageTapped: ((URL) -> Void)? + public var onRenderCompleted: ((RenderMetadata) -> Void)? + public var onRenderFailed: ((RenderError) -> Void)? + + // MARK: - Initialization + + public init(htmlContent: String) { + self.htmlContent = htmlContent + self.configuration = .default + } + + // MARK: - Body + + public var body: some View { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = error { + ErrorView(error: error) + } else if !contentElements.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: configuration.stylesheet.body.paragraphSpacing) { + ForEach(contentElements) { element in + renderElement(element) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Text("No content") + .foregroundColor(.secondary) + } + } + .task { + await parseContent() + } + } + + // MARK: - Rendering + + @ViewBuilder + private func renderElement(_ element: ContentElement) -> some View { + switch element.type { + case .text(let attributedString): + Text(attributedString) + .font(.system(size: configuration.stylesheet.body.fontSize)) + .lineSpacing(configuration.stylesheet.body.lineSpacing) + .textSelection(.enabled) + + case .codeBlock(let code, let language): + CodeBlockAttachment( + code: code, + language: language, + style: configuration.stylesheet.code, + enableHighlighting: configuration.enableCodeHighlighting + ) + + case .image(let imageInfo): + if configuration.enableImages { + AsyncImageAttachment( + url: imageInfo.url, + altText: imageInfo.altText, + style: configuration.stylesheet.image, + quality: configuration.imageQuality + ) + .onTapGesture { + if let url = imageInfo.url { + onImageTapped?(url) + } + } + } + + case .heading(let text, let level): + Text(text) + .font(fontForHeading(level)) + .fontWeight(configuration.stylesheet.heading.fontWeight) + .foregroundColor(configuration.stylesheet.heading.color) + .padding(.top, configuration.stylesheet.heading.topSpacing) + .padding(.bottom, configuration.stylesheet.heading.bottomSpacing) + } + } + + private func fontForHeading(_ level: Int) -> Font { + let size: CGFloat + switch level { + case 1: size = configuration.stylesheet.heading.h1Size + case 2: size = configuration.stylesheet.heading.h2Size + case 3: size = configuration.stylesheet.heading.h3Size + case 4: size = configuration.stylesheet.heading.h4Size + case 5: size = configuration.stylesheet.heading.h5Size + case 6: size = configuration.stylesheet.heading.h6Size + default: size = configuration.stylesheet.heading.h1Size + } + return .system(size: size) + } + + @MainActor + private func parseContent() async { + isLoading = true + error = nil + + do { + // Use actor for background rendering with caching + let result = try await renderActor.render( + html: htmlContent, + configuration: configuration + ) + + self.contentElements = result.elements + self.renderMetadata = result.metadata + self.isLoading = false + + onRenderCompleted?(result.metadata) + + } catch let renderError as RenderError { + self.error = renderError + self.isLoading = false + onRenderFailed?(renderError) + } catch { + let renderError = RenderError.renderingFailed(error.localizedDescription) + self.error = renderError + self.isLoading = false + onRenderFailed?(renderError) + } + } + +} + +// MARK: - Content Element + +public struct ContentElement: Identifiable { + public let id = UUID() + public let type: ElementType + + public init(type: ElementType) { + self.type = type + } + + public enum ElementType { + case text(AttributedString) + case codeBlock(code: String, language: String?) + case image(ImageInfo) + case heading(text: String, level: Int) + + var isImage: Bool { + if case .image = self { return true } + return false + } + } +} + +// MARK: - Configuration + +@available(iOS 18.0, *) +extension RichContentView { + + public func configuration(_ config: RenderConfiguration) -> Self { + var view = self + view.configuration = config + return view + } + + public func onLinkTapped(_ action: @escaping (URL) -> Void) -> Self { + var view = self + view.onLinkTapped = action + return view + } + + public func onMentionTapped(_ action: @escaping (String) -> Void) -> Self { + var view = self + view.onMentionTapped = action + return view + } + + public func onImageTapped(_ action: @escaping (URL) -> Void) -> Self { + var view = self + view.onImageTapped = action + return view + } + + public func onRenderCompleted(_ action: @escaping (RenderMetadata) -> Void) -> Self { + var view = self + view.onRenderCompleted = action + return view + } + + public func onRenderFailed(_ action: @escaping (RenderError) -> Void) -> Self { + var view = self + view.onRenderFailed = action + return view + } +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Views/RichView+Preview.swift b/V2er/Sources/RichView/Views/RichView+Preview.swift new file mode 100644 index 0000000..66b3d98 --- /dev/null +++ b/V2er/Sources/RichView/Views/RichView+Preview.swift @@ -0,0 +1,208 @@ +// +// RichView+Preview.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import SwiftUI + +@available(iOS 18.0, *) +struct RichView_Previews: PreviewProvider { + + static var previews: some View { + Group { + // Basic text with formatting + RichView(htmlContent: Self.basicExample) + .configuration(.default) + .padding() + .previewDisplayName("Basic Text") + + // Links and inline code + RichView(htmlContent: Self.linksAndCodeExample) + .configuration(.default) + .padding() + .previewDisplayName("Links & Code") + + // Mixed formatting + RichView(htmlContent: Self.mixedFormattingExample) + .configuration(.default) + .padding() + .previewDisplayName("Mixed Formatting") + + // Dark mode + RichView(htmlContent: Self.basicExample) + .configuration(.default) + .padding() + .preferredColorScheme(.dark) + .previewDisplayName("Dark Mode") + + // Compact style (for replies) + RichView(htmlContent: Self.replyExample) + .configuration(.compact) + .padding() + .previewDisplayName("Compact Style") + + // Custom stylesheet + RichView(htmlContent: Self.basicExample) + .stylesheet(Self.customStylesheet) + .padding() + .previewDisplayName("Custom Style") + + // Error state + RichView(htmlContent: Self.errorExample) + .configuration(.debug) + .padding() + .previewDisplayName("Error State") + } + } + + // MARK: - Example Content + + static let basicExample = """ +

      This is a bold text and this is italic text.

      +

      Here is a new paragraph with some regular text.

      +
      +

      After a line break, we have more content.

      + """ + + static let linksAndCodeExample = """ +

      Check out this V2EX link and some inline code.

      +
      func helloWorld() {
      +            print("Hello, World!")
      +        }
      +

      Links can be relative or protocol-relative.

      + """ + + static let mixedFormattingExample = """ +

      Main Heading

      +

      This is a paragraph with bold, italic, and code.

      +

      Subheading

      +
      + This is a blockquote with some quoted text. +
      +
        +
      • First item
      • +
      • Second item
      • +
      • Third item
      • +
      +

      Another Section

      +
        +
      1. Numbered item one
      2. +
      3. Numbered item two
      4. +
      + """ + + static let replyExample = """ +

      @username Thanks for sharing! The RichView component looks great.

      +

      I especially like the syntax highlighting feature.

      + """ + + static let errorExample = """ +

      This contains an unsupported tag:

      + +

      This should trigger an error in DEBUG mode.

      + """ + + static var customStylesheet: RenderStylesheet { + var stylesheet = RenderStylesheet.default + stylesheet.body.fontSize = 18 + stylesheet.body.color = Color.purple + stylesheet.link.color = Color.orange + stylesheet.link.underline = true + stylesheet.code.inlineBackgroundColor = Color.yellow.opacity(0.2) + stylesheet.code.inlineTextColor = Color.brown + stylesheet.mention.backgroundColor = Color.blue.opacity(0.2) + stylesheet.mention.textColor = Color.blue + return stylesheet + } +} + +// MARK: - Interactive Preview + +@available(iOS 18.0, *) +struct RichViewInteractivePreview: View { + @State private var htmlInput = """ +

      Interactive RichView Preview

      +

      Edit the HTML below to see live rendering!

      +

      Supports italic, code, and links.

      +

      Mention users like @johndoe or @alice.

      + """ + + @State private var selectedStyle: StylePreset = .default + + enum StylePreset: String, CaseIterable { + case `default` = "Default" + case compact = "Compact" + case accessibility = "Accessibility" + + var configuration: RenderConfiguration { + switch self { + case .default: + return .default + case .compact: + return .compact + case .accessibility: + return RenderConfiguration(stylesheet: .accessibility) + } + } + } + + var body: some View { + VStack(spacing: 0) { + // Style selector + Picker("Style", selection: $selectedStyle) { + ForEach(StylePreset.allCases, id: \.self) { preset in + Text(preset.rawValue).tag(preset) + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding() + + // Rendered view + ScrollView { + RichView(htmlContent: htmlInput) + .configuration(selectedStyle.configuration) + .onLinkTapped { url in + print("Link tapped: \(url)") + } + .onMentionTapped { username in + print("Mention tapped: @\(username)") + } + .onRenderCompleted { metadata in + print("Render completed in \(metadata.renderTime)s") + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: .infinity) + .background(Color.gray.opacity(0.1)) + + Divider() + + // HTML input + TextEditor(text: $htmlInput) + .font(.system(.caption, design: .monospaced)) + .padding(8) + .frame(height: 200) + } + .navigationTitle("RichView Interactive") + .navigationBarTitleDisplayMode(.inline) + } +} + +@available(iOS 18.0, *) +struct RichViewPlayground: View { + var body: some View { + NavigationView { + RichViewInteractivePreview() + } + } +} + +@available(iOS 18.0, *) +struct RichViewPlayground_Previews: PreviewProvider { + static var previews: some View { + RichViewPlayground() + } +} \ No newline at end of file diff --git a/V2er/Sources/RichView/Views/RichView.swift b/V2er/Sources/RichView/Views/RichView.swift new file mode 100644 index 0000000..8c3784e --- /dev/null +++ b/V2er/Sources/RichView/Views/RichView.swift @@ -0,0 +1,250 @@ +// +// RichView.swift +// V2er +// +// Created by RichView on 2025/1/19. +// + +import SwiftUI + +/// A SwiftUI view for rendering HTML content as rich text +@available(iOS 18.0, *) +public struct RichView: View { + + // MARK: - Properties + + /// HTML content to render + private let htmlContent: String + + /// Rendering configuration + private var configuration: RenderConfiguration + + /// Rendered AttributedString + @State private var attributedString: AttributedString? + + /// Loading state + @State private var isLoading = true + + /// Error state + @State private var error: RenderError? + + /// Render metadata + @State private var metadata: RenderMetadata? + + // MARK: - Events + + /// Called when a link is tapped + public var onLinkTapped: ((URL) -> Void)? + + /// Called when a @mention is tapped + public var onMentionTapped: ((String) -> Void)? + + /// Called when rendering starts + public var onRenderStarted: (() -> Void)? + + /// Called when rendering completes + public var onRenderCompleted: ((RenderMetadata) -> Void)? + + /// Called when rendering fails + public var onRenderFailed: ((RenderError) -> Void)? + + // MARK: - Initialization + + /// Initialize with HTML content + public init(htmlContent: String) { + self.htmlContent = htmlContent + self.configuration = .default + } + + // MARK: - Body + + public var body: some View { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = error { + ErrorView(error: error) + } else if let attributedString = attributedString { + Text(attributedString) + .font(.system(size: configuration.stylesheet.body.fontSize)) + .lineSpacing(configuration.stylesheet.body.lineSpacing) + .environment(\.openURL, OpenURLAction { url in + onLinkTapped?(url) + return .handled + }) + } else { + Text("No content") + .foregroundColor(.secondary) + } + } + .task { + await renderContent() + } + } + + // MARK: - Rendering + + @MainActor + private func renderContent() async { + let startTime = Date() + onRenderStarted?() + isLoading = true + error = nil + + do { + // Convert HTML to Markdown + let converter = HTMLToMarkdownConverter( + crashOnUnsupportedTags: configuration.crashOnUnsupportedTags + ) + let markdown = try converter.convert(htmlContent) + + // Render Markdown to AttributedString + let renderer = MarkdownRenderer( + stylesheet: configuration.stylesheet, + enableCodeHighlighting: configuration.enableCodeHighlighting + ) + let rendered = try renderer.render(markdown) + + // Update state + self.attributedString = rendered + self.isLoading = false + + // Create metadata + let endTime = Date() + let renderTime = endTime.timeIntervalSince(startTime) + let metadata = RenderMetadata( + renderTime: renderTime, + htmlLength: htmlContent.count, + markdownLength: markdown.count, + attributedStringLength: self.attributedString?.characters.count ?? 0, + cacheHit: false, + imageCount: 0, + linkCount: 0, + mentionCount: 0 + ) + self.metadata = metadata + + onRenderCompleted?(metadata) + + } catch let renderError as RenderError { + self.error = renderError + self.isLoading = false + onRenderFailed?(renderError) + } catch { + let renderError = RenderError.renderingFailed(error.localizedDescription) + self.error = renderError + self.isLoading = false + onRenderFailed?(renderError) + } + } +} + +// MARK: - Configuration + +@available(iOS 18.0, *) +extension RichView { + + /// Apply configuration to the view + public func configuration(_ config: RenderConfiguration) -> Self { + var view = self + view.configuration = config + return view + } + + /// Apply custom stylesheet + public func stylesheet(_ stylesheet: RenderStylesheet) -> Self { + var view = self + view.configuration.stylesheet = stylesheet + return view + } + + /// Set link tap handler + public func onLinkTapped(_ action: @escaping (URL) -> Void) -> Self { + var view = self + view.onLinkTapped = action + return view + } + + /// Set mention tap handler + public func onMentionTapped(_ action: @escaping (String) -> Void) -> Self { + var view = self + view.onMentionTapped = action + return view + } + + /// Set render started handler + public func onRenderStarted(_ action: @escaping () -> Void) -> Self { + var view = self + view.onRenderStarted = action + return view + } + + /// Set render completed handler + public func onRenderCompleted(_ action: @escaping (RenderMetadata) -> Void) -> Self { + var view = self + view.onRenderCompleted = action + return view + } + + /// Set render failed handler + public func onRenderFailed(_ action: @escaping (RenderError) -> Void) -> Self { + var view = self + view.onRenderFailed = action + return view + } +} + +// MARK: - Error View + +@available(iOS 18.0, *) +struct ErrorView: View { + let error: RenderError + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.largeTitle) + .foregroundColor(.orange) + + Text("Rendering Error") + .font(.headline) + + Text(error.localizedDescription) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Render Metadata + +/// Metadata about the rendering process +public struct RenderMetadata { + /// Time taken to render (in seconds) + public let renderTime: TimeInterval + + /// Original HTML content length + public let htmlLength: Int + + /// Converted Markdown length + public let markdownLength: Int + + /// Final AttributedString length + public let attributedStringLength: Int + + /// Whether the result was retrieved from cache + public let cacheHit: Bool + + /// Number of images in the content + public let imageCount: Int + + /// Number of links in the content + public let linkCount: Int + + /// Number of @mentions in the content + public let mentionCount: Int +} \ No newline at end of file diff --git a/V2er/View/FeedDetail/NewsContentView.swift b/V2er/View/FeedDetail/NewsContentView.swift index d285b85..ef08e70 100644 --- a/V2er/View/FeedDetail/NewsContentView.swift +++ b/V2er/View/FeedDetail/NewsContentView.swift @@ -11,18 +11,142 @@ import SwiftUI struct NewsContentView: View { var contentInfo: FeedDetailInfo.ContentInfo? @Binding var rendered: Bool + @EnvironmentObject var store: Store + @Environment(\.colorScheme) var colorScheme + @State private var showingSafari = false + @State private var safariURL: URL? init(_ contentInfo: FeedDetailInfo.ContentInfo?, rendered: Binding) { self.contentInfo = contentInfo self._rendered = rendered } - + var body: some View { VStack(spacing: 0) { Divider() - HtmlView(html: contentInfo?.html, imgs: contentInfo?.imgs ?? [], rendered: $rendered) + + RichView(htmlContent: contentInfo?.html ?? "") + .configuration(configurationForAppearance()) + .onLinkTapped { url in + handleLinkTap(url) + } + .onRenderCompleted { metadata in + // Mark as rendered after content is ready + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.rendered = true + } + } + .onRenderFailed { error in + print("Render error: \(error)") + self.rendered = true + } + Divider() } + .sheet(isPresented: $showingSafari) { + if let url = safariURL { + SafariView(url: url) + } + } + } + + private func handleLinkTap(_ url: URL) { + // Smart URL routing - parse V2EX URLs and route accordingly + let path = url.path + + // Check if it's a V2EX internal link + if let host = url.host, (host.contains("v2ex.com")) { + // Topic: /t/123456 + if path.contains("/t/"), let topicId = extractTopicId(from: path) { + print("Navigate to topic: \(topicId)") + // TODO: Use proper navigation to FeedDetailPage(id: topicId) + // For now, open in SafariView + openInSafari(url) + return + } + + // Member: /member/username + if path.contains("/member/"), let username = extractUsername(from: path) { + print("Navigate to user: \(username)") + // TODO: Use proper navigation to UserDetailPage(userId: username) + // For now, open in SafariView + openInSafari(url) + return + } + + // Node: /go/nodename + if path.contains("/go/"), let nodeName = extractNodeName(from: path) { + print("Navigate to node: \(nodeName)") + // TODO: Use proper navigation to TagDetailPage + // For now, open in SafariView + openInSafari(url) + return + } + + // Other V2EX pages - open in SafariView + openInSafari(url) + } else { + // External link - open in SafariView (stays in app) + openInSafari(url) + } + } + + private func openInSafari(_ url: URL) { + safariURL = url + showingSafari = true + } + + private func extractTopicId(from path: String) -> String? { + let pattern = "/t/(\\d+)" + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)), + let range = Range(match.range(at: 1), in: path) else { + return nil + } + return String(path[range]) + } + + private func extractUsername(from path: String) -> String? { + let components = path.components(separatedBy: "/") + guard let memberIndex = components.firstIndex(of: "member"), + memberIndex + 1 < components.count else { + return nil + } + return components[memberIndex + 1] + } + + private func extractNodeName(from path: String) -> String? { + let components = path.components(separatedBy: "/") + guard let goIndex = components.firstIndex(of: "go"), + goIndex + 1 < components.count else { + return nil + } + return components[goIndex + 1] + } + + private func configurationForAppearance() -> RenderConfiguration { + var config = RenderConfiguration.default + + // Determine dark mode based on app appearance setting + let appearance = store.appState.settingState.appearance + let isDark: Bool + switch appearance { + case .dark: + isDark = true + case .light: + isDark = false + case .system: + isDark = colorScheme == .dark + } + + // Adjust stylesheet for dark mode + if isDark { + config.stylesheet.body.color = .adaptive(light: .black, dark: .white) + config.stylesheet.heading.color = .adaptive(light: .black, dark: .white) + config.stylesheet.link.color = .adaptive(light: .blue, dark: Color(red: 0.4, green: 0.6, blue: 1.0)) + } + + return config } } diff --git a/V2er/View/FeedDetail/ReplyItemView.swift b/V2er/View/FeedDetail/ReplyItemView.swift index 4aa41c9..64122f5 100644 --- a/V2er/View/FeedDetail/ReplyItemView.swift +++ b/V2er/View/FeedDetail/ReplyItemView.swift @@ -12,6 +12,10 @@ import Atributika struct ReplyItemView: View { var info: FeedDetailInfo.ReplyInfo.Item + @EnvironmentObject var store: Store + @Environment(\.colorScheme) var colorScheme + @State private var showingSafari = false + @State private var safariURL: URL? var body: some View { HStack(alignment: .top) { @@ -45,7 +49,17 @@ struct ReplyItemView: View { .font(.system(size: 14)) .foregroundColor(info.hadThanked ? .red : .secondaryText) } - RichText { info.content } + + RichView(htmlContent: info.content) + .configuration(compactConfigurationForAppearance()) + .onLinkTapped { url in + handleLinkTap(url) + } + .onMentionTapped { username in + print("Navigate to mentioned user: @\(username)") + // TODO: Implement proper navigation to UserDetailPage + } + Text("\(info.floor)楼") .font(.footnote) .foregroundColor(Color.tintColor) @@ -54,5 +68,106 @@ struct ReplyItemView: View { } } .padding(.horizontal, 12) + .sheet(isPresented: $showingSafari) { + if let url = safariURL { + SafariView(url: url) + } + } + } + + private func handleLinkTap(_ url: URL) { + // Smart URL routing - parse V2EX URLs and route accordingly + let path = url.path + + // Check if it's a V2EX internal link + if let host = url.host, (host.contains("v2ex.com")) { + // Topic: /t/123456 + if path.contains("/t/"), let topicId = extractTopicId(from: path) { + print("Navigate to topic: \(topicId)") + // TODO: Use proper navigation to FeedDetailPage(id: topicId) + openInSafari(url) + return + } + + // Member: /member/username + if path.contains("/member/"), let username = extractUsername(from: path) { + print("Navigate to user: \(username)") + // TODO: Use proper navigation to UserDetailPage(userId: username) + openInSafari(url) + return + } + + // Node: /go/nodename + if path.contains("/go/"), let nodeName = extractNodeName(from: path) { + print("Navigate to node: \(nodeName)") + // TODO: Use proper navigation to TagDetailPage + openInSafari(url) + return + } + + // Other V2EX pages - open in SafariView + openInSafari(url) + } else { + // External link - open in SafariView (stays in app) + openInSafari(url) + } + } + + private func openInSafari(_ url: URL) { + safariURL = url + showingSafari = true + } + + private func extractTopicId(from path: String) -> String? { + let pattern = "/t/(\\d+)" + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)), + let range = Range(match.range(at: 1), in: path) else { + return nil + } + return String(path[range]) + } + + private func extractUsername(from path: String) -> String? { + let components = path.components(separatedBy: "/") + guard let memberIndex = components.firstIndex(of: "member"), + memberIndex + 1 < components.count else { + return nil + } + return components[memberIndex + 1] + } + + private func extractNodeName(from path: String) -> String? { + let components = path.components(separatedBy: "/") + guard let goIndex = components.firstIndex(of: "go"), + goIndex + 1 < components.count else { + return nil + } + return components[goIndex + 1] + } + + private func compactConfigurationForAppearance() -> RenderConfiguration { + var config = RenderConfiguration.compact + + // Determine dark mode based on app appearance setting + let appearance = store.appState.settingState.appearance + let isDark: Bool + switch appearance { + case .dark: + isDark = true + case .light: + isDark = false + case .system: + isDark = colorScheme == .dark + } + + // Adjust stylesheet for dark mode + if isDark { + config.stylesheet.body.color = .adaptive(light: .black, dark: .white) + config.stylesheet.link.color = .adaptive(light: .blue, dark: Color(red: 0.4, green: 0.6, blue: 1.0)) + config.stylesheet.mention.textColor = .adaptive(light: Color(red: 0.2, green: 0.4, blue: 0.8), dark: Color(red: 0.4, green: 0.6, blue: 1.0)) + } + + return config } } diff --git a/V2er/View/Widget/Updatable/UpdatableView.swift b/V2er/View/Widget/Updatable/UpdatableView.swift index a645510..4a78413 100644 --- a/V2er/View/Widget/Updatable/UpdatableView.swift +++ b/V2er/View/Widget/Updatable/UpdatableView.swift @@ -32,7 +32,7 @@ struct UpdatableView: View { let state: UpdatableState let onlineStats: OnlineStatsInfo? @State private var previousOnlineCount: Int? = nil - + private var refreshable: Bool { return onRefresh != nil } @@ -54,7 +54,7 @@ struct UpdatableView: View { self.state = state self.onlineStats = onlineStats } - + var body: some View { ScrollViewReader { reader in ScrollView { @@ -86,7 +86,7 @@ struct UpdatableView: View { .overlay { if state.showLoadingView { ZStack { -// Color.almostClear + // Color.almostClear ProgressView() .scaleEffect(1.5) } @@ -113,46 +113,34 @@ struct UpdatableView: View { onScroll?(scrollY) // log("scrollY: \(scrollY), lastScrollY: \(lastScrollY), isRefreshing: \(isRefreshing), boundsDelta:\(boundsDelta)") progress = min(1, max(scrollY / threshold, 0)) - + if progress == 1 && scrollY > lastScrollY && !hapticed { hapticed = true hapticFeedback(.soft) } - + if refreshable && !isRefreshing && scrollY <= threshold && lastScrollY > threshold { isRefreshing = true hapticed = false - // Record current online count before refresh + // Record whether online stats existed before refresh + let hadOnlineStatsBefore = onlineStats != nil previousOnlineCount = onlineStats?.onlineCount - + Task { await onRefresh?() - runInMain { - // Check if online count changed - let currentCount = onlineStats?.onlineCount - let onlineCountChanged = previousOnlineCount != nil && currentCount != nil && previousOnlineCount != currentCount - - if onlineCountChanged { - // Delay hiding if online count changed - Task { - try? await Task.sleep(nanoseconds: 300_000_000) // 300ms - runInMain { - withAnimation { - isRefreshing = false - } - } - } - } else { - withAnimation { - isRefreshing = false - } + // Decide delay (ms): 1200 if we had/now have online stats so users can notice updates; otherwise 0. + let hasOnlineStatsNow = onlineStats != nil + let delayMs = (hadOnlineStatsBefore || hasOnlineStatsNow) ? 1000 : 0 + runInMain(delay: delayMs) { + withAnimation { + isRefreshing = false } } } } - + if loadMoreable && state.hasMoreData && boundsDelta >= 0 @@ -175,7 +163,7 @@ struct UpdatableView: View { private struct AncorView: View { static let coordinateSpaceName = "coordinateSpace.UpdatableView" - + var body: some View { GeometryReader { geometry in Color.clear @@ -227,7 +215,7 @@ extension View { let state = UpdatableState(hasMoreData: hasMoreData, showLoadingView: autoRefresh, scrollToTop: scrollToTop) return self.modifier(UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: onlineStats)) } - + public func updatable(_ state: UpdatableState, onlineStats: OnlineStatsInfo? = nil, refresh: RefreshAction = nil, @@ -236,7 +224,7 @@ extension View { let modifier = UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: onlineStats) return self.modifier(modifier) } - + public func loadMore(_ state: UpdatableState, _ loadMore: LoadMoreAction = nil, onScroll: ScrollAction? = nil) -> some View { @@ -250,7 +238,7 @@ extension View { onScroll: ScrollAction? = nil) -> some View { self.updatable(autoRefresh: autoRefresh, hasMoreData: hasMoreData, onlineStats: nil, refresh: nil, loadMore: loadMore, onScroll: onScroll) } - + public func onScroll(onScroll: ScrollAction?) -> some View { self.updatable(onlineStats: nil, onScroll: onScroll) } @@ -262,7 +250,7 @@ struct UpdatableModifier: ViewModifier { let onScroll: ScrollAction? let state: UpdatableState let onlineStats: OnlineStatsInfo? - + func body(content: Content) -> some View { UpdatableView(onRefresh: onRefresh, onLoadMore: onLoadMore, onScroll: onScroll, state: state, onlineStats: onlineStats) { diff --git a/V2erTests/RichView/HTMLToMarkdownConverterTests.swift b/V2erTests/RichView/HTMLToMarkdownConverterTests.swift new file mode 100644 index 0000000..ac37b8e --- /dev/null +++ b/V2erTests/RichView/HTMLToMarkdownConverterTests.swift @@ -0,0 +1,245 @@ +// +// HTMLToMarkdownConverterTests.swift +// V2erTests +// +// Created by RichView on 2025/1/19. +// + +import XCTest +@testable import V2er + +class HTMLToMarkdownConverterTests: XCTestCase { + + var converter: HTMLToMarkdownConverter! + + override func setUp() { + super.setUp() + converter = HTMLToMarkdownConverter(crashOnUnsupportedTags: false) + } + + override func tearDown() { + converter = nil + super.tearDown() + } + + // MARK: - Basic Tag Tests + + func testParagraphConversion() throws { + let html = "

      This is a paragraph.

      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("This is a paragraph.")) + } + + func testLineBreakConversion() throws { + let html = "Line 1
      Line 2" + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("Line 1 \nLine 2")) + } + + func testStrongTagConversion() throws { + let html = "Bold text" + let markdown = try converter.convert(html) + XCTAssertEqual(markdown.trimmingCharacters(in: .whitespacesAndNewlines), "**Bold text**") + } + + func testBoldTagConversion() throws { + let html = "Bold text" + let markdown = try converter.convert(html) + XCTAssertEqual(markdown.trimmingCharacters(in: .whitespacesAndNewlines), "**Bold text**") + } + + func testEmphasisTagConversion() throws { + let html = "Italic text" + let markdown = try converter.convert(html) + XCTAssertEqual(markdown.trimmingCharacters(in: .whitespacesAndNewlines), "*Italic text*") + } + + func testItalicTagConversion() throws { + let html = "Italic text" + let markdown = try converter.convert(html) + XCTAssertEqual(markdown.trimmingCharacters(in: .whitespacesAndNewlines), "*Italic text*") + } + + func testLinkConversion() throws { + let html = "V2EX" + let markdown = try converter.convert(html) + XCTAssertEqual(markdown.trimmingCharacters(in: .whitespacesAndNewlines), "[V2EX](https://www.v2ex.com)") + } + + func testInlineCodeConversion() throws { + let html = "Use print() to output" + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("`print()`")) + } + + func testCodeBlockConversion() throws { + let html = "
      func test() {\n    return true\n}
      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("```")) + XCTAssertTrue(markdown.contains("func test()")) + } + + // MARK: - V2EX URL Fixing Tests + + func testProtocolRelativeURLFix() throws { + let html = "Link" + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("https://www.v2ex.com")) + } + + func testRelativeURLFix() throws { + let html = "Topic" + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("https://www.v2ex.com/t/123")) + } + + func testImageProtocolRelativeURLFix() throws { + let html = "\"Image\"" + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("https://cdn.v2ex.com")) + } + + // MARK: - Advanced Tag Tests + + func testBlockquoteConversion() throws { + let html = "
      This is a quote
      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("> This is a quote")) + } + + func testUnorderedListConversion() throws { + let html = "
      • Item 1
      • Item 2
      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("- Item 1")) + XCTAssertTrue(markdown.contains("- Item 2")) + } + + func testOrderedListConversion() throws { + let html = "
      1. First
      2. Second
      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("1. First")) + XCTAssertTrue(markdown.contains("2. Second")) + } + + func testHeading1Conversion() throws { + let html = "

      Title

      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("# Title")) + } + + func testHeading2Conversion() throws { + let html = "

      Subtitle

      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("## Subtitle")) + } + + func testImageConversion() throws { + let html = "\"Description\"" + let markdown = try converter.convert(html) + XCTAssertEqual(markdown.trimmingCharacters(in: .whitespacesAndNewlines), + "![Description](https://example.com/image.png)") + } + + func testHorizontalRuleConversion() throws { + let html = "
      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("---")) + } + + // MARK: - Complex Content Tests + + func testMixedFormattingConversion() throws { + let html = "

      This has bold and italic text.

      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("**bold**")) + XCTAssertTrue(markdown.contains("*italic*")) + } + + func testNestedElementsConversion() throws { + let html = "

      Check this link

      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("[**this link**](https://example.com)")) + } + + func testMultipleParagraphsConversion() throws { + let html = "

      First paragraph

      Second paragraph

      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("First paragraph")) + XCTAssertTrue(markdown.contains("Second paragraph")) + // Check for paragraph separation + let lines = markdown.components(separatedBy: "\n") + XCTAssertTrue(lines.count > 2) + } + + // MARK: - Unsupported Tag Tests + + func testUnsupportedTagWithCrashDisabled() throws { + converter = HTMLToMarkdownConverter(crashOnUnsupportedTags: false) + let html = "" + + XCTAssertThrowsError(try converter.convert(html)) { error in + guard let renderError = error as? RenderError else { + XCTFail("Expected RenderError") + return + } + + switch renderError { + case .unsupportedTag(let tag, _): + XCTAssertEqual(tag, "video") + default: + XCTFail("Expected unsupportedTag error") + } + } + } + + func testDivContainerProcessing() throws { + let html = "
      Content in div
      " + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("Content in div")) + } + + func testSpanContainerProcessing() throws { + let html = "Content in span" + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("Content in span")) + } + + // MARK: - Edge Cases + + func testEmptyHTML() throws { + let html = "" + let markdown = try converter.convert(html) + XCTAssertEqual(markdown, "") + } + + func testWhitespaceOnlyHTML() throws { + let html = " \n\t " + let markdown = try converter.convert(html) + XCTAssertEqual(markdown, "") + } + + func testMalformedHTML() throws { + let html = "

      Unclosed paragraph" + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("Unclosed paragraph")) + } + + func testSpecialCharacterEscaping() throws { + // Test that special Markdown characters are escaped + let html = "Text with * and _ and #" + let markdown = try converter.convert(html) + XCTAssertTrue(markdown.contains("\\*")) + XCTAssertTrue(markdown.contains("\\_")) + XCTAssertTrue(markdown.contains("\\#")) + } + + // MARK: - Performance Tests + + func testPerformanceLargeHTML() throws { + let repeatedHTML = String(repeating: "

      This is a test paragraph with bold text.

      ", count: 100) + + measure { + _ = try? converter.convert(repeatedHTML) + } + } +} \ No newline at end of file diff --git a/V2erTests/RichView/LanguageDetectorTests.swift b/V2erTests/RichView/LanguageDetectorTests.swift new file mode 100644 index 0000000..d604067 --- /dev/null +++ b/V2erTests/RichView/LanguageDetectorTests.swift @@ -0,0 +1,329 @@ +// +// LanguageDetectorTests.swift +// V2erTests +// +// Created by RichView on 2025/1/19. +// + +import XCTest +@testable import V2er + +class LanguageDetectorTests: XCTestCase { + + // MARK: - Swift Detection + + func testSwiftDetection() { + let swiftCode = """ + func hello() { + let name = "World" + print("Hello, \\(name)!") + } + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: swiftCode), "swift") + } + + func testSwiftWithImport() { + let swiftCode = "import Foundation\nvar x = 10" + XCTAssertEqual(LanguageDetector.detectLanguage(from: swiftCode), "swift") + } + + // MARK: - Python Detection + + func testPythonDetection() { + let pythonCode = """ + def hello(): + print("Hello, World!") + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: pythonCode), "python") + } + + func testPythonWithImport() { + let pythonCode = "import numpy as np\nfrom scipy import stats" + XCTAssertEqual(LanguageDetector.detectLanguage(from: pythonCode), "python") + } + + // MARK: - JavaScript Detection + + func testJavaScriptDetection() { + let jsCode = """ + const greeting = "Hello"; + console.log(greeting); + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: jsCode), "javascript") + } + + func testJavaScriptArrowFunction() { + let jsCode = "const add = (a, b) => a + b;" + XCTAssertEqual(LanguageDetector.detectLanguage(from: jsCode), "javascript") + } + + // MARK: - Java Detection + + func testJavaDetection() { + let javaCode = """ + public class Hello { + public static void main(String[] args) { + System.out.println("Hello"); + } + } + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: javaCode), "java") + } + + // MARK: - Go Detection + + func testGoDetection() { + let goCode = """ + package main + import "fmt" + func main() { + fmt.Println("Hello") + } + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: goCode), "go") + } + + // MARK: - Rust Detection + + func testRustDetection() { + let rustCode = """ + fn main() { + println!("Hello, world!"); + } + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: rustCode), "rust") + } + + // MARK: - C++ Detection + + func testCppDetection() { + let cppCode = """ + #include + int main() { + std::cout << "Hello" << std::endl; + return 0; + } + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: cppCode), "cpp") + } + + // MARK: - Ruby Detection + + func testRubyDetection() { + let rubyCode = """ + def hello + puts "Hello, World!" + end + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: rubyCode), "ruby") + } + + // MARK: - PHP Detection + + func testPHPDetection() { + let phpCode = """ + + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: phpCode), "php") + } + + // MARK: - Shell/Bash Detection + + func testBashDetection() { + let bashCode = """ + #!/bin/bash + echo "Hello, World!" + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: bashCode), "bash") + } + + // MARK: - SQL Detection + + func testSQLDetection() { + let sqlCode = """ + SELECT * FROM users WHERE age > 18; + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: sqlCode), "sql") + } + + func testSQLUpdate() { + let sqlCode = "UPDATE users SET name = 'John' WHERE id = 1;" + XCTAssertEqual(LanguageDetector.detectLanguage(from: sqlCode), "sql") + } + + // MARK: - HTML Detection + + func testHTMLDetection() { + let htmlCode = """ + + + +

      Hello

      + + + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: htmlCode), "html") + } + + func testHTMLSimple() { + let htmlCode = "
      Content
      " + XCTAssertEqual(LanguageDetector.detectLanguage(from: htmlCode), "html") + } + + // MARK: - CSS Detection + + func testCSSDetection() { + let cssCode = """ + .container { + color: blue; + font-size: 16px; + } + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: cssCode), "css") + } + + // MARK: - JSON Detection + + func testJSONDetection() { + let jsonCode = """ + { + "name": "John", + "age": 30 + } + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: jsonCode), "json") + } + + func testJSONArray() { + let jsonCode = """ + [ + {"id": 1}, + {"id": 2} + ] + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: jsonCode), "json") + } + + // MARK: - Markdown Detection + + func testMarkdownDetection() { + let markdownCode = """ + # Heading 1 + ## Heading 2 + This is **bold** text. + ``` + code block + ``` + """ + + XCTAssertEqual(LanguageDetector.detectLanguage(from: markdownCode), "markdown") + } + + // MARK: - Unknown Language + + func testUnknownLanguage() { + let unknownCode = "some random text that doesn't match any language" + XCTAssertNil(LanguageDetector.detectLanguage(from: unknownCode)) + } + + func testEmptyCode() { + XCTAssertNil(LanguageDetector.detectLanguage(from: "")) + } + + func testWhitespaceOnly() { + XCTAssertNil(LanguageDetector.detectLanguage(from: " \n\t ")) + } + + // MARK: - Display Name Tests + + func testDisplayNames() { + XCTAssertEqual(LanguageDetector.displayName(for: "swift"), "Swift") + XCTAssertEqual(LanguageDetector.displayName(for: "python"), "Python") + XCTAssertEqual(LanguageDetector.displayName(for: "js"), "JavaScript") + XCTAssertEqual(LanguageDetector.displayName(for: "typescript"), "TypeScript") + XCTAssertEqual(LanguageDetector.displayName(for: "cpp"), "C++") + XCTAssertEqual(LanguageDetector.displayName(for: "go"), "Go") + XCTAssertEqual(LanguageDetector.displayName(for: "rust"), "Rust") + XCTAssertEqual(LanguageDetector.displayName(for: "bash"), "Shell") + XCTAssertEqual(LanguageDetector.displayName(for: "unknown"), "UNKNOWN") + } + + // MARK: - Ambiguous Cases + + func testAmbiguousJavaScriptVsTypeScript() { + // JavaScript + let jsCode = "const x = 10;" + XCTAssertEqual(LanguageDetector.detectLanguage(from: jsCode), "javascript") + + // TypeScript would need type annotations to distinguish + // For now, both are detected as JavaScript + } + + func testAmbiguousCVsCpp() { + // C code (without C++ features) + let cCode = """ + #include + int main() { + printf("Hello"); + return 0; + } + """ + + // Will be detected as cpp since we look for #include + // which is common to both C and C++ + XCTAssertEqual(LanguageDetector.detectLanguage(from: cCode), "cpp") + } + + // MARK: - Performance Tests + + func testPerformanceWithLargeCode() { + let largeCode = String(repeating: "function test() { console.log('test'); }\n", count: 1000) + + measure { + _ = LanguageDetector.detectLanguage(from: largeCode) + } + } + + func testPerformanceWithMultipleDetections() { + let codes = [ + "func test() { }", + "def test(): pass", + "const x = 10;", + "public class Test { }", + "package main", + "fn main() { }", + "#include ", + "def test\n puts 'hello'\n end", + "", + "#!/bin/bash", + "SELECT * FROM table;", + "", + ".test { color: red; }", + "{\"key\": \"value\"}", + "# Heading" + ] + + measure { + for code in codes { + _ = LanguageDetector.detectLanguage(from: code) + } + } + } +} \ No newline at end of file diff --git a/V2erTests/RichView/MarkdownRendererTests.swift b/V2erTests/RichView/MarkdownRendererTests.swift new file mode 100644 index 0000000..9747038 --- /dev/null +++ b/V2erTests/RichView/MarkdownRendererTests.swift @@ -0,0 +1,342 @@ +// +// MarkdownRendererTests.swift +// V2erTests +// +// Created by RichView on 2025/1/19. +// + +import XCTest +import SwiftUI +@testable import V2er + +@available(iOS 15.0, *) +class MarkdownRendererTests: XCTestCase { + + var renderer: MarkdownRenderer! + + override func setUp() { + super.setUp() + renderer = MarkdownRenderer( + stylesheet: .default, + enableCodeHighlighting: true + ) + } + + override func tearDown() { + renderer = nil + super.tearDown() + } + + // MARK: - Plain Text Tests + + func testPlainTextRendering() throws { + let markdown = "This is plain text" + let attributed = try renderer.render(markdown) + + XCTAssertEqual(attributed.characters.count, markdown.count + 1) // +1 for newline + XCTAssertTrue(attributed.description.contains("This is plain text")) + } + + // MARK: - Text Formatting Tests + + func testBoldTextRendering() throws { + let markdown = "This is **bold** text" + let attributed = try renderer.render(markdown) + + // Check that the text contains bold + let string = String(attributed.characters) + XCTAssertTrue(string.contains("bold")) + } + + func testItalicTextRendering() throws { + let markdown = "This is *italic* text" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("italic")) + } + + func testInlineCodeRendering() throws { + let markdown = "Use `print()` function" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("print()")) + } + + func testLinkRendering() throws { + let markdown = "Visit [V2EX](https://www.v2ex.com)" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("V2EX")) + } + + func testMentionRendering() throws { + let markdown = "Hello @username!" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("@username")) + } + + // MARK: - Heading Tests + + func testHeading1Rendering() throws { + let markdown = "# Heading 1" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("Heading 1")) + } + + func testHeading2Rendering() throws { + let markdown = "## Heading 2" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("Heading 2")) + } + + func testMultipleHeadingsRendering() throws { + let markdown = """ + # H1 + ## H2 + ### H3 + #### H4 + ##### H5 + ###### H6 + """ + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("H1")) + XCTAssertTrue(string.contains("H2")) + XCTAssertTrue(string.contains("H3")) + XCTAssertTrue(string.contains("H4")) + XCTAssertTrue(string.contains("H5")) + XCTAssertTrue(string.contains("H6")) + } + + // MARK: - Code Block Tests + + func testCodeBlockRendering() throws { + let markdown = """ + ``` + func test() { + print("Hello") + } + ``` + """ + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("func test()")) + XCTAssertTrue(string.contains("print")) + } + + func testCodeBlockWithLanguageRendering() throws { + let markdown = """ + ```swift + let x = 10 + ``` + """ + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("let x = 10")) + } + + // MARK: - Blockquote Tests + + func testBlockquoteRendering() throws { + let markdown = "> This is a quote" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("This is a quote")) + } + + // MARK: - List Tests + + func testUnorderedListRendering() throws { + let markdown = """ + - Item 1 + - Item 2 + - Item 3 + """ + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("• Item 1")) + XCTAssertTrue(string.contains("• Item 2")) + XCTAssertTrue(string.contains("• Item 3")) + } + + func testOrderedListRendering() throws { + let markdown = """ + 1. First + 2. Second + 3. Third + """ + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("1. First")) + XCTAssertTrue(string.contains("2. Second")) + XCTAssertTrue(string.contains("3. Third")) + } + + // MARK: - Mixed Content Tests + + func testMixedFormattingRendering() throws { + let markdown = "Text with **bold**, *italic*, and `code`" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("bold")) + XCTAssertTrue(string.contains("italic")) + XCTAssertTrue(string.contains("code")) + } + + func testComplexDocumentRendering() throws { + let markdown = """ + # Title + + This is a paragraph with **bold** and *italic* text. + + ## Section + + > A blockquote + + - List item 1 + - List item 2 + + ``` + code block + ``` + + Visit [link](https://example.com) and mention @user. + """ + + let attributed = try renderer.render(markdown) + let string = String(attributed.characters) + + XCTAssertTrue(string.contains("Title")) + XCTAssertTrue(string.contains("Section")) + XCTAssertTrue(string.contains("bold")) + XCTAssertTrue(string.contains("italic")) + XCTAssertTrue(string.contains("blockquote")) + XCTAssertTrue(string.contains("List item")) + XCTAssertTrue(string.contains("code block")) + XCTAssertTrue(string.contains("link")) + XCTAssertTrue(string.contains("@user")) + } + + // MARK: - Stylesheet Tests + + func testCompactStylesheetRendering() throws { + renderer = MarkdownRenderer( + stylesheet: .compact, + enableCodeHighlighting: true + ) + + let markdown = "# Heading\n\nParagraph text" + let attributed = try renderer.render(markdown) + + // Just verify it renders without error with compact style + XCTAssertTrue(attributed.characters.count > 0) + } + + func testCustomStylesheetRendering() throws { + var customStylesheet = RenderStylesheet.default + customStylesheet.body.fontSize = 20 + customStylesheet.link.color = .orange + + renderer = MarkdownRenderer( + stylesheet: customStylesheet, + enableCodeHighlighting: true + ) + + let markdown = "Text with [link](https://example.com)" + let attributed = try renderer.render(markdown) + + // Just verify it renders without error with custom style + XCTAssertTrue(attributed.characters.count > 0) + } + + // MARK: - Edge Cases + + func testEmptyMarkdownRendering() throws { + let markdown = "" + let attributed = try renderer.render(markdown) + + XCTAssertEqual(attributed.characters.count, 0) + } + + func testWhitespaceOnlyRendering() throws { + let markdown = " \n\n " + let attributed = try renderer.render(markdown) + + // Should have newlines but minimal content + XCTAssertTrue(attributed.characters.count <= 3) + } + + func testHorizontalRuleRendering() throws { + let markdown = "---" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("—")) + } + + // MARK: - Performance Tests + + func testPerformanceLargeDocument() throws { + let repeatedMarkdown = String( + repeating: "# Heading\n\nThis is a paragraph with **bold** text.\n\n", + count: 100 + ) + + measure { + _ = try? renderer.render(repeatedMarkdown) + } + } + + func testPerformanceMixedContent() throws { + let complexMarkdown = """ + # Main Title + + This is the introduction with **bold**, *italic*, and `code`. + + ## Section 1 + + > Important quote here + + - Item one with [link](https://example.com) + - Item two with @mention + - Item three with more content + + ```swift + func example() { + let x = 10 + print(x) + } + ``` + + ### Subsection + + 1. First point + 2. Second point + 3. Third point + + Regular paragraph with more text. + """ + + let repeated = String(repeating: complexMarkdown + "\n\n", count: 20) + + measure { + _ = try? renderer.render(repeated) + } + } +} \ No newline at end of file diff --git a/V2erTests/RichView/MentionParserTests.swift b/V2erTests/RichView/MentionParserTests.swift new file mode 100644 index 0000000..86ea5f4 --- /dev/null +++ b/V2erTests/RichView/MentionParserTests.swift @@ -0,0 +1,248 @@ +// +// MentionParserTests.swift +// V2erTests +// +// Created by RichView on 2025/1/19. +// + +import XCTest +@testable import V2er + +class MentionParserTests: XCTestCase { + + // MARK: - Basic Detection Tests + + func testSimpleMention() { + let text = "Hello @username!" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 1) + XCTAssertEqual(mentions.first?.username, "username") + XCTAssertEqual(mentions.first?.fullText, "@username") + } + + func testMultipleMentions() { + let text = "@alice and @bob are here, also @charlie" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 3) + XCTAssertEqual(mentions[0].username, "alice") + XCTAssertEqual(mentions[1].username, "bob") + XCTAssertEqual(mentions[2].username, "charlie") + } + + func testMentionWithUnderscore() { + let text = "Thanks @user_name" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 1) + XCTAssertEqual(mentions.first?.username, "user_name") + } + + func testMentionWithNumbers() { + let text = "Hello @user123" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 1) + XCTAssertEqual(mentions.first?.username, "user123") + } + + func testMentionAtStartOfLine() { + let text = "@username is here" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 1) + XCTAssertEqual(mentions.first?.username, "username") + } + + func testMentionAtEndOfLine() { + let text = "Thanks to @username" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 1) + XCTAssertEqual(mentions.first?.username, "username") + } + + // MARK: - Email Exclusion Tests + + func testEmailNotDetected() { + let text = "Contact me at user@example.com" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 0, "Email should not be detected as mention") + } + + func testEmailValidation() { + XCTAssertTrue(MentionParser.isEmail("user@example.com")) + XCTAssertTrue(MentionParser.isEmail("test.user@domain.co.uk")) + XCTAssertFalse(MentionParser.isEmail("@username")) + XCTAssertFalse(MentionParser.isEmail("not-an-email")) + } + + func testMentionAndEmail() { + let text = "Email @john at john@example.com" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 1) + XCTAssertEqual(mentions.first?.username, "john") + } + + // MARK: - Edge Cases + + func testNoMentions() { + let text = "Hello world, no mentions here" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 0) + } + + func testEmptyString() { + let text = "" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 0) + } + + func testOnlyAtSymbol() { + let text = "@ alone" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 0) + } + + func testMentionWithSpecialCharactersNotDetected() { + let text = "@user-name @user.name @user#name" + let mentions = MentionParser.findMentions(in: text) + + // Hyphens, dots, and # should not be part of mentions + XCTAssertEqual(mentions.count, 0) + } + + func testConsecutiveMentions() { + let text = "@alice@bob" + let mentions = MentionParser.findMentions(in: text) + + // Should detect alice but not bob (no space) + XCTAssertEqual(mentions.count, 1) + XCTAssertEqual(mentions.first?.username, "alice") + } + + // MARK: - Username Extraction Tests + + func testExtractUsernameValid() { + XCTAssertEqual(MentionParser.extractUsername(from: "@username"), "username") + XCTAssertEqual(MentionParser.extractUsername(from: "@user_name"), "user_name") + XCTAssertEqual(MentionParser.extractUsername(from: "@user123"), "user123") + } + + func testExtractUsernameInvalid() { + XCTAssertNil(MentionParser.extractUsername(from: "username")) + XCTAssertNil(MentionParser.extractUsername(from: "@")) + XCTAssertNil(MentionParser.extractUsername(from: "")) + XCTAssertNil(MentionParser.extractUsername(from: "@user-name")) + XCTAssertNil(MentionParser.extractUsername(from: "@user.name")) + } + + // MARK: - Position Detection Tests + + func testIsMentionAtPosition() { + let text = "Hello @username, how are you?" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertTrue(MentionParser.isMention(at: 7, in: text)) // @ position + XCTAssertTrue(MentionParser.isMention(at: 10, in: text)) // middle of username + XCTAssertFalse(MentionParser.isMention(at: 0, in: text)) // before mention + XCTAssertFalse(MentionParser.isMention(at: 20, in: text)) // after mention + } + + // MARK: - Profile URL Tests + + func testProfileURL() { + let mention = Mention( + fullText: "@johndoe", + username: "johndoe", + range: NSRange(location: 0, length: 8) + ) + + XCTAssertNotNil(mention.profileURL) + XCTAssertEqual(mention.profileURL?.absoluteString, "https://www.v2ex.com/member/johndoe") + } + + // MARK: - V2EX Username Validation Tests + + func testValidV2EXUsernames() { + XCTAssertTrue(MentionParser.isValidV2EXUsername("abc")) + XCTAssertTrue(MentionParser.isValidV2EXUsername("user_name")) + XCTAssertTrue(MentionParser.isValidV2EXUsername("user123")) + XCTAssertTrue(MentionParser.isValidV2EXUsername("a1b2c3")) + XCTAssertTrue(MentionParser.isValidV2EXUsername("username12345")) + } + + func testInvalidV2EXUsernames() { + XCTAssertFalse(MentionParser.isValidV2EXUsername("ab")) // Too short + XCTAssertFalse(MentionParser.isValidV2EXUsername("a")) // Too short + XCTAssertFalse(MentionParser.isValidV2EXUsername("")) // Empty + XCTAssertFalse(MentionParser.isValidV2EXUsername("verylongusernamethatexceedstwentycharacters")) // Too long + XCTAssertFalse(MentionParser.isValidV2EXUsername("user-name")) // Invalid character + XCTAssertFalse(MentionParser.isValidV2EXUsername("user.name")) // Invalid character + XCTAssertFalse(MentionParser.isValidV2EXUsername("user name")) // Space + } + + // MARK: - Replace Mentions Tests + + func testReplaceMentions() { + let text = "Hello @alice and @bob!" + let replaced = MentionParser.replaceMentions(in: text) { mention in + "[\(mention.username)]" + } + + XCTAssertEqual(replaced, "Hello [alice] and [bob]!") + } + + func testReplaceMentionsWithURL() { + let text = "Thanks @john" + let replaced = MentionParser.replaceMentions(in: text) { mention in + "\(mention.fullText)" + } + + XCTAssertTrue(replaced.contains("https://www.v2ex.com/member/john")) + } + + // MARK: - Real-World V2EX Content Tests + + func testV2EXStyleMention() { + let text = "感谢 @Livid 的分享" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 1) + XCTAssertEqual(mentions.first?.username, "Livid") + } + + func testMultipleV2EXMentions() { + let text = "@jack 你好,cc @tom @jerry" + let mentions = MentionParser.findMentions(in: text) + + XCTAssertEqual(mentions.count, 3) + XCTAssertEqual(mentions[0].username, "jack") + XCTAssertEqual(mentions[1].username, "tom") + XCTAssertEqual(mentions[2].username, "jerry") + } + + // MARK: - Performance Tests + + func testPerformanceWithManyMentions() { + let text = String(repeating: "Hello @user1 and @user2 ", count: 100) + + measure { + _ = MentionParser.findMentions(in: text) + } + } + + func testPerformanceLargeText() { + let text = String(repeating: "This is a long text without mentions. ", count: 1000) + + measure { + _ = MentionParser.findMentions(in: text) + } + } +} \ No newline at end of file diff --git a/V2erTests/RichView/RenderPerformanceTests.swift b/V2erTests/RichView/RenderPerformanceTests.swift new file mode 100644 index 0000000..8e41cf4 --- /dev/null +++ b/V2erTests/RichView/RenderPerformanceTests.swift @@ -0,0 +1,361 @@ +// +// RenderPerformanceTests.swift +// V2erTests +// +// Created by RichView on 2025/1/19. +// + +import XCTest +@testable import V2er + +@available(iOS 15.0, *) +class RenderPerformanceTests: XCTestCase { + + var renderActor: RenderActor! + + override func setUp() { + super.setUp() + renderActor = RenderActor() + RichViewCache.shared.clearAll() + } + + override func tearDown() { + RichViewCache.shared.clearAll() + super.tearDown() + } + + // MARK: - Small Content Benchmarks + + func testPerformanceSmallTextOnly() { + let html = "

      This is a simple paragraph with bold text.

      " + let config = RenderConfiguration.default + + measure { + let expectation = XCTestExpectation(description: "Render small text") + + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + } + + func testPerformanceSmallWithCode() { + let html = """ +

      Here's some code:

      +
      func hello() {
      +                print("Hello")
      +            }
      + """ + let config = RenderConfiguration.default + + measure { + let expectation = XCTestExpectation(description: "Render small code") + + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + } + + // MARK: - Medium Content Benchmarks + + func testPerformanceMediumContent() { + let html = """ +

      Title

      +

      This is a paragraph with bold and italic.

      +
      A quote here
      +
      let x = 10
      +            print(x)
      +
        +
      • Item 1
      • +
      • Item 2
      • +
      • Item 3
      • +
      +

      Thanks @user for the feedback!

      + """ + let config = RenderConfiguration.default + + measure { + let expectation = XCTestExpectation(description: "Render medium content") + + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 2.0) + } + } + + // MARK: - Large Content Benchmarks + + func testPerformanceLargeContent() { + let html = String(repeating: """ +

      Section Title

      +

      This is a paragraph with bold and italic text.

      +
      func example() {
      +                let value = 100
      +                return value
      +            }
      +

      Some more text here with a link.

      + """, count: 10) + + let config = RenderConfiguration.default + + measure { + let expectation = XCTestExpectation(description: "Render large content") + + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + } + + // MARK: - Cache Performance Tests + + func testPerformanceCacheHit() { + let html = "

      Cached content

      " + let config = RenderConfiguration.default + + // First render to populate cache + let expectation1 = XCTestExpectation(description: "First render") + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation1.fulfill() + } + wait(for: [expectation1], timeout: 1.0) + + // Measure cache hit performance + measure { + let expectation2 = XCTestExpectation(description: "Cache hit render") + + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation2.fulfill() + } + + wait(for: [expectation2], timeout: 1.0) + } + } + + func testPerformanceCacheMiss() { + let config = RenderConfiguration.default + + // Clear cache before each iteration + measure { + RichViewCache.shared.clearAll() + + let html = "

      Uncached content \(UUID().uuidString)

      " + let expectation = XCTestExpectation(description: "Cache miss render") + + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + } + + // MARK: - Real-World V2EX Content Tests + + func testPerformanceV2EXTypicalReply() { + let html = """ +

      @原作者 说得对,我也遇到了这个问题。

      +

      我的解决方案是使用 RichView 来渲染内容。

      +
      let view = RichView(htmlContent: content)
      +            view.configuration(.compact)
      +

      效果很不错!

      + """ + let config = RenderConfiguration.compact + + measure { + let expectation = XCTestExpectation(description: "V2EX reply render") + + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + } + + func testPerformanceV2EXTypicalTopic() { + let html = """ +

      如何优化 iOS 应用的渲染性能?

      + +

      最近在开发一个 V2EX 客户端,遇到了渲染性能问题。

      + +

      问题描述

      + +

      使用 WKWebView 渲染 HTML 内容时,性能开销很大:

      + +
        +
      • 渲染时间约 200ms
      • +
      • 内存占用高达 200MB (100 条内容)
      • +
      • 滚动帧率只有 30 FPS
      • +
      + +

      解决方案

      + +

      我开发了一个新的渲染组件 RichView

      + +
      // 初始化
      +            let richView = RichView(htmlContent: html)
      +            richView.configuration(.default)
      +
      +            // 事件处理
      +            richView.onLinkTapped { url in
      +                print("Tapped: \\(url)")
      +            }
      + +

      性能对比

      + +
      + RichView 的渲染时间降低到 50ms 以内,内存占用减少到 10MB。 +
      + +

      感谢 @Livid 提供的建议!

      + +

      项目地址:GitHub

      + """ + + let config = RenderConfiguration.default + + measure { + let expectation = XCTestExpectation(description: "V2EX topic render") + + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 2.0) + } + } + + // MARK: - Concurrent Rendering Tests + + func testPerformanceConcurrentRendering() { + let htmlContents = (0..<10).map { index in + "

      Content \(index) with bold text.

      " + } + let config = RenderConfiguration.default + + measure { + let expectation = XCTestExpectation(description: "Concurrent rendering") + expectation.expectedFulfillmentCount = htmlContents.count + + for html in htmlContents { + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 5.0) + } + } + + // MARK: - Memory Performance Tests + + func testMemoryPerformanceWithCaching() { + let htmlContents = (0..<100).map { index in + "

      Test content \(index) with some text.

      " + } + let config = RenderConfiguration.default + + measure(metrics: [XCTMemoryMetric()]) { + let expectation = XCTestExpectation(description: "Memory test") + expectation.expectedFulfillmentCount = htmlContents.count + + for html in htmlContents { + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 10.0) + } + } + + // MARK: - Baseline Comparison Tests + + func testPerformanceBaseline() { + // This establishes a baseline for simple HTML conversion + let html = "

      Simple text

      " + let converter = HTMLToMarkdownConverter(crashOnUnsupportedTags: false) + + measure { + _ = try? converter.convert(html) + } + } + + func testPerformanceBaselineMarkdownRendering() { + // Baseline for Markdown to AttributedString + let markdown = "**Bold** and *italic* text with `code`" + let renderer = MarkdownRenderer(stylesheet: .default) + + measure { + _ = try? renderer.render(markdown) + } + } + + // MARK: - Configuration Impact Tests + + func testPerformanceWithoutCaching() { + let html = "

      Content without caching

      " + var config = RenderConfiguration.default + config.enableCaching = false + + measure { + let expectation = XCTestExpectation(description: "No cache render") + + Task { + _ = try? await renderActor.render(html: html, configuration: config) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + } + + func testPerformanceCompactVsDefault() { + let html = "

      Test content

      " + + // Default configuration + measure { + let expectation = XCTestExpectation(description: "Default config") + + Task { + _ = try? await renderActor.render(html: html, configuration: .default) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + // Compact configuration + measure { + let expectation = XCTestExpectation(description: "Compact config") + + Task { + _ = try? await renderActor.render(html: html, configuration: .compact) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + } +} \ No newline at end of file diff --git a/V2erTests/RichView/RichViewCacheTests.swift b/V2erTests/RichView/RichViewCacheTests.swift new file mode 100644 index 0000000..c3df87f --- /dev/null +++ b/V2erTests/RichView/RichViewCacheTests.swift @@ -0,0 +1,359 @@ +// +// RichViewCacheTests.swift +// V2erTests +// +// Created by RichView on 2025/1/19. +// + +import XCTest +@testable import V2er + +@available(iOS 15.0, *) +class RichViewCacheTests: XCTestCase { + + var cache: RichViewCache! + + override func setUp() { + super.setUp() + cache = RichViewCache.shared + cache.clearAll() + } + + override func tearDown() { + cache.clearAll() + super.tearDown() + } + + // MARK: - Markdown Cache Tests + + func testMarkdownCacheHit() { + let html = "

      Hello World

      " + let markdown = "Hello World" + + // First access - miss + XCTAssertNil(cache.getMarkdown(forHTML: html)) + + // Set markdown + cache.setMarkdown(markdown, forHTML: html) + + // Second access - hit + let retrieved = cache.getMarkdown(forHTML: html) + XCTAssertEqual(retrieved, markdown) + } + + func testMarkdownCacheMiss() { + let html = "

      Hello World

      " + + let retrieved = cache.getMarkdown(forHTML: html) + XCTAssertNil(retrieved) + } + + func testMarkdownCacheWithDifferentHTML() { + let html1 = "

      Hello

      " + let html2 = "

      World

      " + let markdown1 = "Hello" + let markdown2 = "World" + + cache.setMarkdown(markdown1, forHTML: html1) + cache.setMarkdown(markdown2, forHTML: html2) + + XCTAssertEqual(cache.getMarkdown(forHTML: html1), markdown1) + XCTAssertEqual(cache.getMarkdown(forHTML: html2), markdown2) + } + + // MARK: - AttributedString Cache Tests + + func testAttributedStringCacheHit() { + let markdown = "**Bold** text" + let attributed = AttributedString("Bold text") + + // First access - miss + XCTAssertNil(cache.getAttributedString(forMarkdown: markdown)) + + // Set attributed string + cache.setAttributedString(attributed, forMarkdown: markdown) + + // Second access - hit + let retrieved = cache.getAttributedString(forMarkdown: markdown) + XCTAssertNotNil(retrieved) + XCTAssertEqual(retrieved?.characters.count, attributed.characters.count) + } + + func testAttributedStringCacheMiss() { + let markdown = "Some text" + + let retrieved = cache.getAttributedString(forMarkdown: markdown) + XCTAssertNil(retrieved) + } + + // MARK: - Content Elements Cache Tests + + func testContentElementsCacheHit() { + let html = "

      Hello

      " + let elements = [ContentElement(type: .heading(text: "Hello", level: 1))] + + // First access - miss + XCTAssertNil(cache.getContentElements(forHTML: html)) + + // Set elements + cache.setContentElements(elements, forHTML: html) + + // Second access - hit + let retrieved = cache.getContentElements(forHTML: html) + XCTAssertNotNil(retrieved) + XCTAssertEqual(retrieved?.count, elements.count) + } + + func testContentElementsCacheMiss() { + let html = "

      Hello

      " + + let retrieved = cache.getContentElements(forHTML: html) + XCTAssertNil(retrieved) + } + + // MARK: - Cache Statistics Tests + + func testCacheStatisticsInitial() { + let stats = cache.getStatistics() + + XCTAssertEqual(stats.markdownHits, 0) + XCTAssertEqual(stats.markdownMisses, 0) + XCTAssertEqual(stats.attributedStringHits, 0) + XCTAssertEqual(stats.attributedStringMisses, 0) + XCTAssertEqual(stats.totalHits, 0) + XCTAssertEqual(stats.totalMisses, 0) + XCTAssertEqual(stats.hitRate, 0.0) + } + + func testCacheStatisticsAfterHits() { + let html = "

      Test

      " + let markdown = "Test" + + // Cause 2 misses + _ = cache.getMarkdown(forHTML: html) + _ = cache.getMarkdown(forHTML: html) + + // Set and cause 3 hits + cache.setMarkdown(markdown, forHTML: html) + _ = cache.getMarkdown(forHTML: html) + _ = cache.getMarkdown(forHTML: html) + _ = cache.getMarkdown(forHTML: html) + + let stats = cache.getStatistics() + XCTAssertEqual(stats.markdownHits, 3) + XCTAssertEqual(stats.markdownMisses, 2) + XCTAssertEqual(stats.totalHits, 3) + XCTAssertEqual(stats.totalMisses, 2) + XCTAssertEqual(stats.hitRate, 0.6, accuracy: 0.01) + } + + func testCacheStatisticsHitRates() { + let html = "

      Test

      " + let markdown = "Test" + let attributed = AttributedString("Test") + + // Markdown: 1 miss, 2 hits + _ = cache.getMarkdown(forHTML: html) + cache.setMarkdown(markdown, forHTML: html) + _ = cache.getMarkdown(forHTML: html) + _ = cache.getMarkdown(forHTML: html) + + // AttributedString: 1 miss, 1 hit + _ = cache.getAttributedString(forMarkdown: markdown) + cache.setAttributedString(attributed, forMarkdown: markdown) + _ = cache.getAttributedString(forMarkdown: markdown) + + let stats = cache.getStatistics() + XCTAssertEqual(stats.markdownHitRate, 2.0/3.0, accuracy: 0.01) + XCTAssertEqual(stats.attributedStringHitRate, 0.5, accuracy: 0.01) + } + + // MARK: - Cache Clear Tests + + func testClearMarkdownCache() { + let html = "

      Test

      " + let markdown = "Test" + + cache.setMarkdown(markdown, forHTML: html) + XCTAssertNotNil(cache.getMarkdown(forHTML: html)) + + cache.clearMarkdownCache() + XCTAssertNil(cache.getMarkdown(forHTML: html)) + } + + func testClearAttributedStringCache() { + let markdown = "Test" + let attributed = AttributedString("Test") + + cache.setAttributedString(attributed, forMarkdown: markdown) + XCTAssertNotNil(cache.getAttributedString(forMarkdown: markdown)) + + cache.clearAttributedStringCache() + XCTAssertNil(cache.getAttributedString(forMarkdown: markdown)) + } + + func testClearContentElementsCache() { + let html = "

      Test

      " + let elements = [ContentElement(type: .heading(text: "Test", level: 1))] + + cache.setContentElements(elements, forHTML: html) + XCTAssertNotNil(cache.getContentElements(forHTML: html)) + + cache.clearContentElementsCache() + XCTAssertNil(cache.getContentElements(forHTML: html)) + } + + func testClearAllCaches() { + let html = "

      Test

      " + let markdown = "Test" + let attributed = AttributedString("Test") + let elements = [ContentElement(type: .heading(text: "Test", level: 1))] + + cache.setMarkdown(markdown, forHTML: html) + cache.setAttributedString(attributed, forMarkdown: markdown) + cache.setContentElements(elements, forHTML: html) + + XCTAssertNotNil(cache.getMarkdown(forHTML: html)) + XCTAssertNotNil(cache.getAttributedString(forMarkdown: markdown)) + XCTAssertNotNil(cache.getContentElements(forHTML: html)) + + cache.clearAll() + + XCTAssertNil(cache.getMarkdown(forHTML: html)) + XCTAssertNil(cache.getAttributedString(forMarkdown: markdown)) + XCTAssertNil(cache.getContentElements(forHTML: html)) + + // Statistics should also be reset + let stats = cache.getStatistics() + XCTAssertEqual(stats.totalHits, 0) + XCTAssertEqual(stats.totalMisses, 0) + } + + // MARK: - Cache Key Tests + + func testSameContentSameCacheKey() { + let html1 = "

      Hello World

      " + let html2 = "

      Hello World

      " + let markdown = "Hello World" + + cache.setMarkdown(markdown, forHTML: html1) + + // Same content should use same cache key + let retrieved = cache.getMarkdown(forHTML: html2) + XCTAssertEqual(retrieved, markdown) + } + + func testDifferentContentDifferentCacheKey() { + let html1 = "

      Hello

      " + let html2 = "

      World

      " + let markdown1 = "Hello" + + cache.setMarkdown(markdown1, forHTML: html1) + + // Different content should use different cache key + let retrieved = cache.getMarkdown(forHTML: html2) + XCTAssertNil(retrieved) + } + + // MARK: - Performance Tests + + func testCachePerformanceLargeContent() { + let largeHTML = String(repeating: "

      Test paragraph with some content.

      ", count: 100) + let largeMarkdown = String(repeating: "Test paragraph with some content.\n\n", count: 100) + + // Measure cache set + measure { + cache.setMarkdown(largeMarkdown, forHTML: largeHTML) + } + } + + func testCachePerformanceRetrieveLargeContent() { + let largeHTML = String(repeating: "

      Test paragraph with some content.

      ", count: 100) + let largeMarkdown = String(repeating: "Test paragraph with some content.\n\n", count: 100) + + cache.setMarkdown(largeMarkdown, forHTML: largeHTML) + + // Measure cache get + measure { + _ = cache.getMarkdown(forHTML: largeHTML) + } + } + + func testCachePerformanceMultipleEntries() { + let count = 100 + + // Prepare test data + let testData = (0..Test \(index)

      ", markdown: "Test \(index)") + } + + // Measure setting multiple entries + measure { + for data in testData { + cache.setMarkdown(data.markdown, forHTML: data.html) + } + } + } + + func testCachePerformanceHitRate() { + let testData = (0..<50).map { index in + (html: "

      Test \(index)

      ", markdown: "Test \(index)") + } + + // Set all entries + for data in testData { + cache.setMarkdown(data.markdown, forHTML: data.html) + } + + // Measure retrieval performance + measure { + for data in testData { + _ = cache.getMarkdown(forHTML: data.html) + } + } + + // Verify high hit rate + let stats = cache.getStatistics() + XCTAssertGreaterThan(stats.markdownHitRate, 0.95) + } + + // MARK: - Concurrent Access Tests + + func testConcurrentWrites() { + let expectation = XCTestExpectation(description: "Concurrent writes") + expectation.expectedFulfillmentCount = 10 + + // Write from multiple threads + for i in 0..<10 { + DispatchQueue.global().async { + let html = "

      Test \(i)

      " + let markdown = "Test \(i)" + self.cache.setMarkdown(markdown, forHTML: html) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 5.0) + } + + func testConcurrentReads() { + let html = "

      Test

      " + let markdown = "Test" + + cache.setMarkdown(markdown, forHTML: html) + + let expectation = XCTestExpectation(description: "Concurrent reads") + expectation.expectedFulfillmentCount = 100 + + // Read from multiple threads + for _ in 0..<100 { + DispatchQueue.global().async { + let retrieved = self.cache.getMarkdown(forHTML: html) + XCTAssertEqual(retrieved, markdown) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 5.0) + } +} \ No newline at end of file diff --git a/V2erTests/URLRouterTests.swift b/V2erTests/URLRouterTests.swift new file mode 100644 index 0000000..b8167e5 --- /dev/null +++ b/V2erTests/URLRouterTests.swift @@ -0,0 +1,212 @@ +// +// URLRouterTests.swift +// V2erTests +// +// Created by RichView on 2025/1/19. +// Copyright © 2025 lessmore.io. All rights reserved. +// + +import XCTest +@testable import V2er + +final class URLRouterTests: XCTestCase { + + // MARK: - Topic URL Tests + + func testTopicURL_Full() { + let result = URLRouter.parse("https://www.v2ex.com/t/123456") + if case .topic(let id) = result { + XCTAssertEqual(id, "123456") + } else { + XCTFail("Expected topic result, got \(result)") + } + } + + func testTopicURL_WithFragment() { + let result = URLRouter.parse("https://www.v2ex.com/t/123456#reply789") + if case .topic(let id) = result { + XCTAssertEqual(id, "123456") + } else { + XCTFail("Expected topic result, got \(result)") + } + } + + func testTopicURL_Relative() { + let result = URLRouter.parse("/t/123456") + if case .topic(let id) = result { + XCTAssertEqual(id, "123456") + } else { + XCTFail("Expected topic result, got \(result)") + } + } + + func testTopicURL_AlternativeHost() { + let result = URLRouter.parse("https://v2ex.com/t/999888") + if case .topic(let id) = result { + XCTAssertEqual(id, "999888") + } else { + XCTFail("Expected topic result, got \(result)") + } + } + + // MARK: - Node URL Tests + + func testNodeURL_Full() { + let result = URLRouter.parse("https://www.v2ex.com/go/swift") + if case .node(let name) = result { + XCTAssertEqual(name, "swift") + } else { + XCTFail("Expected node result, got \(result)") + } + } + + func testNodeURL_Relative() { + let result = URLRouter.parse("/go/programming") + if case .node(let name) = result { + XCTAssertEqual(name, "programming") + } else { + XCTFail("Expected node result, got \(result)") + } + } + + func testNodeURL_WithQueryParams() { + let result = URLRouter.parse("https://www.v2ex.com/go/swift?p=2") + if case .node(let name) = result { + XCTAssertEqual(name, "swift") + } else { + XCTFail("Expected node result, got \(result)") + } + } + + // MARK: - Member URL Tests + + func testMemberURL_Full() { + let result = URLRouter.parse("https://www.v2ex.com/member/livid") + if case .member(let username) = result { + XCTAssertEqual(username, "livid") + } else { + XCTFail("Expected member result, got \(result)") + } + } + + func testMemberURL_Relative() { + let result = URLRouter.parse("/member/testuser") + if case .member(let username) = result { + XCTAssertEqual(username, "testuser") + } else { + XCTFail("Expected member result, got \(result)") + } + } + + // MARK: - External URL Tests + + func testExternalURL_Google() { + let result = URLRouter.parse("https://www.google.com") + if case .external(let url) = result { + XCTAssertEqual(url.host, "www.google.com") + } else { + XCTFail("Expected external result, got \(result)") + } + } + + func testExternalURL_GitHub() { + let result = URLRouter.parse("https://github.com/v2er-app/iOS") + if case .external(let url) = result { + XCTAssertTrue(url.absoluteString.contains("github.com")) + } else { + XCTFail("Expected external result, got \(result)") + } + } + + // MARK: - Webview URL Tests + + func testWebviewURL_About() { + let result = URLRouter.parse("https://www.v2ex.com/about") + if case .webview(let url) = result { + XCTAssertTrue(url.path.contains("about")) + } else { + XCTFail("Expected webview result, got \(result)") + } + } + + func testWebviewURL_Help() { + let result = URLRouter.parse("https://www.v2ex.com/help/currency") + if case .webview = result { + XCTAssertTrue(true) + } else { + XCTFail("Expected webview result, got \(result)") + } + } + + // MARK: - Invalid URL Tests + + func testInvalidURL_Empty() { + let result = URLRouter.parse("") + if case .invalid = result { + XCTAssertTrue(true) + } else { + XCTFail("Expected invalid result, got \(result)") + } + } + + func testInvalidURL_Malformed() { + let result = URLRouter.parse("ht!tp://invalid url with spaces") + if case .invalid = result { + XCTAssertTrue(true) + } else { + XCTFail("Expected invalid result, got \(result)") + } + } + + // MARK: - Navigation Destination Tests + + func testDestination_Topic() { + let destination = URLRouter.destination(from: "https://www.v2ex.com/t/123456") + XCTAssertEqual(destination, .feedDetail(id: "123456")) + } + + func testDestination_Member() { + let destination = URLRouter.destination(from: "/member/livid") + XCTAssertEqual(destination, .userDetail(username: "livid")) + } + + func testDestination_Node() { + let destination = URLRouter.destination(from: "/go/swift") + XCTAssertEqual(destination, .tagDetail(name: "swift")) + } + + func testDestination_External() { + let destination = URLRouter.destination(from: "https://www.google.com") + XCTAssertNil(destination) + } + + // MARK: - Edge Cases + + func testTopicURL_TrailingSlash() { + let result = URLRouter.parse("https://www.v2ex.com/t/123456/") + if case .topic(let id) = result { + XCTAssertEqual(id, "123456") + } else { + XCTFail("Expected topic result, got \(result)") + } + } + + func testNodeURL_MultipleSegments() { + // Should only extract the first segment after /go/ + let result = URLRouter.parse("https://www.v2ex.com/go/swift/extra") + if case .node(let name) = result { + XCTAssertEqual(name, "swift") + } else { + XCTFail("Expected node result, got \(result)") + } + } + + func testMemberURL_WithTab() { + let result = URLRouter.parse("https://www.v2ex.com/member/livid/topics") + if case .member(let username) = result { + XCTAssertEqual(username, "livid") + } else { + XCTFail("Expected member result, got \(result)") + } + } +}