Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ All notable changes to RichText are documented here.

---

## 3.1.1

Two regressions from 3.1.0.

**macOS backgrounds were no longer transparent.**

3.1.0 guarded the `drawsBackground` KVC call with `responds(to:)`.
KVC does not need that selector, it falls back to the `_drawsBackground` ivar.
So the guard always failed: the background stayed opaque, and every macOS launch
emitted a spurious `webViewConfigurationFailed`.

Now uses `underPageBackgroundColor`, public API on iOS 15 and macOS 12.

**`cssGenerationFailed` fired during a SwiftUI view update.**

The handler ran synchronously from `makeUIView` and `updateUIView`.
Mutating state inside it produced "Modifying state during view update" β€” the
warning #72 had already fixed.

Errors are now delivered on the next main-queue turn.

Both are covered by regression tests.
macOS transparency is asserted against a real hosted `WKWebView` rather than a
string comparison, which is why 3.1.0 shipped without catching it.

**Docs.**

Fixed examples that did not compile: ambiguous `.blue` and `.cyan` color
literals, a redeclared `let css`, and `fullScreenCover(item:)` bound to a
non-`Identifiable` `String`.

Removed 18 trailing-whitespace errors.

---

## 3.1.0

A correctness release. Every change below is source-compatible - nothing public was removed.
Expand All @@ -30,7 +65,7 @@ RichText(html: html)
switch error {
case .cssGenerationFailed:
// fontColor or linkColor holds an invalid hex value, so the browser
// is dropping the declaration and the colour falls back to default
// is dropping the declaration and the color falls back to default
break
case .webViewConfigurationFailed:
// macOS: the web view background could not be made transparent
Expand Down Expand Up @@ -75,7 +110,7 @@ Nothing is removed; all of these still compile and behave as before.
| `RichTextConstants.bodyCSS` | nothing - it was never applied |
| `Configuration.isColorsImportant` | `.colorPreference(forceColor:)`, or `ColorSet(light:dark:isImportant:)` |

`Configuration.isColorsImportant` deserves a note: it was recorded but never read during CSS generation, so `Configuration(isColorsImportant: .all)` silently did nothing. `!important` comes from the colour sets alone.
`Configuration.isColorsImportant` deserves a note: it was recorded but never read during CSS generation, so `Configuration(isColorsImportant: .all)` silently did nothing. `!important` comes from the color sets alone.

```swift
// ❌ had no effect
Expand Down Expand Up @@ -129,7 +164,7 @@ Configuration(fontColor: ColorSet(light: "000000", dark: "FFFFFF", isImportant:
Version 3.0.0 maintains **100% backward compatibility** for v2.x users while providing a clear path to modern APIs:

- βœ… **Zero Breaking Changes**: All existing v2.x code works unchanged
- βœ… **Automatic Performance**: Better async/await performance and font rendering without code changes
- βœ… **Automatic Performance**: Better async/await performance and font rendering without code changes
- βœ… **Guided Migration**: Helpful deprecation warnings with clear modern API alternatives
- βœ… **Additive Enhancement**: New features are optional and don't affect existing functionality
- βœ… **Future-Proof**: Modern architecture ready for Swift 6+ and future iOS/macOS versions
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Styling, light and dark theming, custom fonts, media callbacks and typed errors

- **Cross-platform** β€” iOS 15.0+ and macOS 12.0+, Swift 5.9+
- **Automatic sizing** β€” the view resizes itself as the content lays out, including late images, web fonts and `<details>` toggles
- **Theming** β€” automatic light/dark mode, custom colour sets, transparent or custom backgrounds
- **Theming** β€” automatic light/dark mode, custom color sets, transparent or custom backgrounds
- **Typography** β€” system, monospaced, italic, custom and bundled fonts, with Dynamic Type support
- **Interactive media** β€” click callbacks for images and videos
- **Link handling** β€” Safari, `SFSafariViewController`, or your own handler
Expand Down Expand Up @@ -94,7 +94,7 @@ See [the API reference](docs/API.md) for every modifier, and [advanced usage](do
```
https://github.com/NuPlay/RichText.git
```
3. Select version rule: **"Up to Next Major Version"** from **"3.1.0"**
3. Select version rule: **"Up to Next Major Version"** from **"3.1.1"**
4. Click **Add Package**

### Manual Package.swift
Expand All @@ -103,7 +103,7 @@ Add RichText to your `Package.swift`:

```swift
dependencies: [
.package(url: "https://github.com/NuPlay/RichText.git", .upToNextMajor(from: "3.1.0"))
.package(url: "https://github.com/NuPlay/RichText.git", .upToNextMajor(from: "3.1.1"))
],
targets: [
.target(
Expand Down
2 changes: 1 addition & 1 deletion Sources/RichText/Models/ColorSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public struct ColorSet: Equatable {
}

private func isValidHexColor(_ hex: String) -> Bool {
// `#RGB`, `#RGBA`, `#RRGGBB` and `#RRGGBBAA` are all valid CSS colours. The previous
// `#RGB`, `#RGBA`, `#RRGGBB` and `#RRGGBBAA` are all valid CSS colors. The previous
// 6-or-8 rule rejected the two shorthand forms as invalid even though they render
// fine, and disagreed with `BackgroundColor.isHexColorLiteral`, which already
// accepted all four lengths.
Expand Down
4 changes: 2 additions & 2 deletions Sources/RichText/Models/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ public struct Configuration {
/// deprecation warning.
var storedColorPreference: ColorPreference

/// The requested colour enforcement preference.
/// The requested color enforcement preference.
///
/// - Warning: Assigning this does not change the generated CSS. `!important` is driven
/// entirely by ``ColorSet/isImportant`` on ``fontColor`` and ``linkColor``, and this
/// property has never been read during CSS generation. Use
/// ``RichText/colorPreference(forceColor:)``, which sets both colour sets, or pass
/// ``RichText/colorPreference(forceColor:)``, which sets both color sets, or pass
/// `ColorSet(light:dark:isImportant:)` directly.
@available(*, deprecated, message: "Has no effect on the generated CSS. Use .colorPreference(forceColor:) on the view, or pass ColorSet(light:dark:isImportant:) for fontColor/linkColor.")
public var isColorsImportant: ColorPreference {
Expand Down
6 changes: 3 additions & 3 deletions Sources/RichText/Models/RichTextConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public struct RichTextConstants {
//
// The percentage `min-height`/`max-height` that used to sit here resolved against a
// containing block of `auto` height, so both were no-ops and neither did anything for
// the responsive behaviour. `min-height: 100%` in particular would have stretched every
// the responsive behavior. `min-height: 100%` in particular would have stretched every
// image to the full container height the moment a definite height appeared.
//
// `loading: lazy` was also dropped: `loading` is an HTML attribute, not a CSS property,
Expand Down Expand Up @@ -268,12 +268,12 @@ extension RichTextConstants {
"iframe{width:100%; height:\(height)px; border: none;}"
}

/// Link colouring.
/// Link coloring.
public static func linkCSS(color: String) -> String {
"a:link {color: \(color); transition: color 0.2s ease;}"
}

/// A `<style>` block for a single colour scheme, followed by the opening `<BODY>` tag.
/// A `<style>` block for a single color scheme, followed by the opening `<BODY>` tag.
public static func styleDocument(css: String, customCSS: String) -> String {
"""
<style type='text/css'>
Expand Down
2 changes: 1 addition & 1 deletion Sources/RichText/Models/RichTextEnums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public enum RichTextError: LocalizedError {
/// The web view could not be configured as requested, so it will not look or behave as
/// the configuration asked for.
case webViewConfigurationFailed
/// The generated CSS will not be applied as intended, for example because a colour was
/// The generated CSS will not be applied as intended, for example because a color was
/// given as an invalid hex value and the browser will drop the declaration.
case cssGenerationFailed
/// A media click message from the page could not be handled.
Expand Down
2 changes: 1 addition & 1 deletion Sources/RichText/Views/RichText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import SwiftUI
/// .fontType(.system)
/// .lineHeight(150)
/// .imageRadius(8)
/// .linkColor(light: .blue, dark: .cyan)
/// .linkColor(light: Color.blue, dark: Color.cyan)
/// .placeholder {
/// ProgressView("Loading...")
/// }
Expand Down
40 changes: 22 additions & 18 deletions Sources/RichText/Views/Webview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,18 +104,9 @@ extension WebView: NSViewRepresentable {
// Set delegate
webview.navigationDelegate = context.coordinator

// Configure appearance.
//
// `drawsBackground` is not part of WKWebView's public API, it is reached through KVC.
// `setValue(_:forKey:)` raises NSUnknownKeyException if the key ever goes away, and
// that is an Objective-C exception Swift cannot catch, so it would take the host app
// down. Probe for the setter and report a configuration failure instead.
if webview.responds(to: NSSelectorFromString("setDrawsBackground:")) {
webview.setValue(false, forKey: "drawsBackground")
} else {
webViewLogger.error("WKWebView no longer exposes drawsBackground; background will not be transparent")
conf.errorHandler?(.webViewConfigurationFailed)
}
// `underPageBackgroundColor` is the public WebKit API for the color behind a page.
// It is available from macOS 12, which is this package's minimum deployment target.
webview.underPageBackgroundColor = .clear

// Load HTML content
loadHTMLIfNeeded(in: webview, coordinator: context.coordinator)
Expand Down Expand Up @@ -307,6 +298,19 @@ extension WebView {
}

extension WebView {
/// Reports an error after the current SwiftUI representable update has completed.
@MainActor
func reportError(_ error: RichTextError) {
guard let errorHandler = conf.errorHandler else { return }

// Calling user code synchronously from make/update can mutate SwiftUI state while a
// view update is in progress. Capture the handler belonging to the failing
// configuration, then deliver the error on the next main-queue turn.
DispatchQueue.main.async {
errorHandler(error)
}
}

/// Loads HTML content into the WebView safely on main thread, skipping redundant reloads
/// - Parameters:
/// - webView: The WKWebView instance to load content into
Expand All @@ -315,21 +319,21 @@ extension WebView {
private func loadHTMLIfNeeded(in webView: WKWebView, coordinator: Coordinator) {
let htmlString = generateHTML()
let baseURL = conf.baseURL

guard coordinator.loadedHTML != htmlString || coordinator.loadedBaseURL != baseURL else {
webViewLogger.debug("Skipping reload, generated HTML and base URL are unchanged")
return
}

// A malformed hex value is not rejected anywhere: it reaches the stylesheet as
// `color: #whatever`, and the browser drops that declaration along with the rest of
// the rule. The text or link colour then silently falls back to the default, which is
// the rule. The text or link color then silently falls back to the default, which is
// very hard to trace from the outside, so surface it.
if !conf.fontColor.isValid || !conf.linkColor.isValid {
webViewLogger.error("Invalid hex colour in fontColor or linkColor; the declaration will be dropped by the browser")
conf.errorHandler?(.cssGenerationFailed)
webViewLogger.error("Invalid hex color in fontColor or linkColor; the declaration will be dropped by the browser")
reportError(.cssGenerationFailed)
}

coordinator.loadedHTML = htmlString
coordinator.loadedBaseURL = baseURL

Expand Down
2 changes: 1 addition & 1 deletion TestApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The test app covers all major features:
- **Height Tracks the Content**: Opening a `<details>`, late-loading images and web fonts all resize the view instead of being clipped
- **No Redundant Reloads**: The document is reloaded only when the HTML or base URL changes, so in-page state survives a SwiftUI update
- **Dynamic Type Keeps `fontType`**: `supportsDynamicType` no longer discards `.monospaced`, `.italic` or `.customName`
- **More Errors Reported**: Invalid hex colours raise `.cssGenerationFailed`; macOS transparency failures raise `.webViewConfigurationFailed`
- **More Errors Reported**: Invalid hex colors raise `.cssGenerationFailed`; macOS transparency failures raise `.webViewConfigurationFailed`

### New in v3.0.0
- **Better Font Rendering**: Monospaced and italic fonts should display correctly
Expand Down
81 changes: 80 additions & 1 deletion Tests/RichTextTests/RichTextSwiftTestingTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Testing
import SwiftUI
import WebKit
@testable import RichText

#if canImport(UIKit)
Expand Down Expand Up @@ -52,7 +53,7 @@ struct RichTextAllTests {
#expect(config.storedColorPreference == .all)
}

@Test("Color preference is only applied through the colour sets")
@Test("Color preference is only applied through the color sets")
func colorPreferenceOnlyAppliesThroughColorSets() {
// `isColorsImportant` is recorded but never read during CSS generation, which is
// why it is deprecated. `!important` comes from the ColorSets alone.
Expand Down Expand Up @@ -325,6 +326,84 @@ struct RichTextAllTests {
}
}

@Suite("WebView Behavior Tests", .serialized)
struct WebViewBehaviorTests {
#if canImport(AppKit)
@Test("macOS RichText creates a transparent WebView without a false configuration error")
@MainActor
func macOSRichTextCreatesTransparentWebView() async throws {
var errors: [RichTextError] = []
let configuration = Configuration(errorHandler: { errors.append($0) })
let host = NSHostingView(
rootView: RichText(html: "<p>Test</p>", configuration: configuration)
.frame(width: 320, height: 100)
)
host.frame = NSRect(x: 0, y: 0, width: 320, height: 100)
host.layoutSubtreeIfNeeded()

let webView = try #require(findWKWebView(in: host))
await waitForMainQueueTurn()

#expect(webView.underPageBackgroundColor.alphaComponent == 0)
#expect(!errors.contains(where: isWebViewConfigurationFailure))

withExtendedLifetime(host) {}
}

@MainActor
private func findWKWebView(in view: NSView) -> WKWebView? {
if let webView = view as? WKWebView {
return webView
}

for subview in view.subviews {
if let webView = findWKWebView(in: subview) {
return webView
}
}
return nil
}

private func isWebViewConfigurationFailure(_ error: RichTextError) -> Bool {
if case .webViewConfigurationFailed = error {
return true
}
return false
}

@MainActor
private func waitForMainQueueTurn() async {
await withCheckedContinuation { continuation in
DispatchQueue.main.async {
continuation.resume()
}
}
}
#endif

@Test("Error callbacks are deferred until after the current view update")
@MainActor
func errorCallbacksAreDeferred() async {
let callbackWasDeferred: Bool = await withCheckedContinuation { continuation in
var reportCallReturned = false
let configuration = Configuration(errorHandler: { _ in
continuation.resume(returning: reportCallReturned)
})
let webView = WebView(
width: 100,
dynamicHeight: .constant(0),
html: "<p>Invalid CSS</p>",
configuration: configuration
)

webView.reportError(.cssGenerationFailed)
reportCallReturned = true
}

#expect(callbackWasDeferred)
}
}

@Suite("Constants Validation Tests")
struct ConstantsTests {

Expand Down
Loading