From 479182f9df6726dcc30d2e01c4606f7141a4b939 Mon Sep 17 00:00:00 2001
From: David Beck A paragraph with bold, italic, and a link that wraps onto another line. First ordered item Continuation of the same item.
Date: Wed, 14 Oct 2026 14:08:06 -0700
Subject: [PATCH 1/5] Apply shared SwiftFormat style
---
.swiftformat | 127 +++++++++
Sources/SimpleHTML/BlockTextLine.swift | 6 +-
Sources/SimpleHTML/BlockTextView.swift | 8 +-
.../HTMLAttributedStringBuilder.swift | 34 +--
Sources/SimpleHTML/HTMLElement.swift | 8 +-
Sources/SimpleHTML/HTMLParser.swift | 22 +-
Sources/SimpleHTML/HTMLView.swift | 2 +-
Sources/SimpleHTML/String+AlphaNumerals.swift | 2 +-
.../BlockTextViewSnapshotTests.swift | 22 +-
.../SimpleHTMLTests/BlockTextViewTests.swift | 8 +-
.../SimpleHTMLTests/Character+HTMLTests.swift | 4 +-
.../DocumentationSnapshotTests.swift | 261 +++++++++---------
.../HTMLAttributedStringBuilderTests.swift | 51 ++--
Tests/SimpleHTMLTests/HTMLElementTests.swift | 2 +-
14 files changed, 346 insertions(+), 211 deletions(-)
create mode 100644 .swiftformat
diff --git a/.swiftformat b/.swiftformat
new file mode 100644
index 0000000..aa9a062
--- /dev/null
+++ b/.swiftformat
@@ -0,0 +1,127 @@
+--language-mode 6.2
+--acronyms ID,URL,UUID
+--allman false
+--allow-partial-wrapping true
+--anonymous-for-each convert
+--asset-literals visual-width
+--async-capturing
+--before-marks
+--binary-grouping 4,8
+--blank-line-after-switch-case multiline-only
+--call-site-paren default
+--category-mark "MARK: %c"
+--class-threshold 0
+--closing-paren balanced
+--closure-void remove
+--complex-attributes preserve
+--computed-var-attributes preserve
+--conditional-assignment after-property
+--conflict-markers reject
+--date-format system
+--decimal-grouping 3,6
+--default-test-suite-attributes
+--doc-comments before-declarations
+--else-position same-line
+--empty-braces no-space
+--enum-namespaces always
+--enum-threshold 0
+--equatable-macro none
+--exponent-case lowercase
+--exponent-grouping disabled
+--extension-acl on-extension
+--extension-mark "MARK: - %t + %c"
+--extension-threshold 0
+--file-macro "#file"
+--fraction-grouping disabled
+--fragment false
+--func-attributes preserve
+--generic-types
+--group-blank-lines true
+--grouped-extension "MARK: %c"
+--guard-else auto
+--header strip
+--hex-grouping 4,8
+--hex-literal-case uppercase
+--ifdef indent
+--import-grouping testable-last
+--indent tab
+--indent-case false
+--indent-strings false
+--inferred-types always
+--init-coder-nil false
+--lifecycle
+--line-after-marks true
+--line-between-guards false
+--linebreaks lf
+--mark-categories true
+--mark-class-threshold 0
+--mark-enum-threshold 0
+--mark-extension-threshold 0
+--mark-extensions always
+--mark-struct-threshold 0
+--mark-types always
+--markdown-files ignore
+--max-width none
+--modifier-order
+--never-trailing
+--nil-init remove
+--no-space-operators
+--no-wrap-operators
+--non-complex-attributes
+--octal-grouping 4,8
+--operator-func spaced
+--organization-mode visibility
+--organize-types actor,class,enum,struct
+--pattern-let hoist
+--preserve-acronyms
+--preserve-decls
+--preserved-property-types Package
+--property-types infer-locals-only
+--ranges spaced
+--redundant-async tests-only
+--redundant-throws tests-only
+--self remove
+--self-required
+--semicolons inline-only
+--short-optionals except-properties
+--single-line-for-each ignore
+--smart-tabs enabled
+--some-any true
+--sort-swiftui-properties none
+--sorted-patterns
+--stored-var-attributes preserve
+--strip-unused-args always
+--struct-threshold 0
+--swift-version 6.2
+--tab-width unspecified
+--throw-capturing
+--timezone system
+--trailing-closures
+--trailing-commas always
+--trim-whitespace always
+--type-attributes preserve
+--type-blank-lines remove
+--type-body-marks preserve
+--type-delimiter space-after
+--type-mark "MARK: - %t"
+--type-marks
+--type-order
+--url-macro none
+--visibility-marks
+--visibility-order
+--void-type Void
+--wrap-arguments preserve
+--wrap-collections preserve
+--wrap-conditions preserve
+--wrap-effects preserve
+--wrap-enum-cases always
+--wrap-parameters default
+--wrap-return-type preserve
+--wrap-string-interpolation default
+--wrap-ternary default
+--wrap-type-aliases preserve
+--xcode-indentation disabled
+--xctest-symbols
+--yoda-swap always
+--disable andOperator,blankLinesBetweenChainedFunctions,conditionalAssignment,noForceUnwrapInTests,redundantRawValues,redundantSelf,swiftTestingTestCaseNames,unusedArguments
+--enable isEmpty
diff --git a/Sources/SimpleHTML/BlockTextLine.swift b/Sources/SimpleHTML/BlockTextLine.swift
index 2437e6e..54fe06b 100644
--- a/Sources/SimpleHTML/BlockTextLine.swift
+++ b/Sources/SimpleHTML/BlockTextLine.swift
@@ -22,7 +22,9 @@ extension PresentationIntent {
var listItem: IntentType? {
components.first {
- if case .listItem = $0.kind { return true }
+ if case .listItem = $0.kind {
+ return true
+ }
return false
}
}
@@ -71,7 +73,7 @@ struct BlockTextLine: View {
// size based on font from underlying text
.frame(
width: proxy.size.height / 3,
- height: proxy.size.height / 3
+ height: proxy.size.height / 3,
)
// center in geometry reader
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
diff --git a/Sources/SimpleHTML/BlockTextView.swift b/Sources/SimpleHTML/BlockTextView.swift
index 24a9195..abf9511 100644
--- a/Sources/SimpleHTML/BlockTextView.swift
+++ b/Sources/SimpleHTML/BlockTextView.swift
@@ -51,13 +51,13 @@ public struct BlockTextView: View {
public var attributedString: AttributedString
/// Creates a block renderer for HTML, Markdown, or custom attributed text.
- ///
+ ///
/// - Parameter attributedString: Text with Foundation block presentation intents.
public init(attributedString: AttributedString) {
self.attributedString = attributedString
}
- // Group consecutive presentation-intent runs by their root block.
+ /// Group consecutive presentation-intent runs by their root block.
var blocks: [[AttributedSubstring]] {
var result: [[AttributedSubstring]] = []
var previousRoot: PresentationIntent.IntentType?
@@ -72,8 +72,8 @@ public struct BlockTextView: View {
return result
}
- // Markdown list items can contain several paragraphs or a nested list.
- // Only the first paragraph of each item receives its marker.
+ /// Markdown list items can contain several paragraphs or a nested list.
+ /// Only the first paragraph of each item receives its marker.
func showsListMarker(in block: [AttributedSubstring], at index: Int) -> Bool {
guard let item = block[index].presentationIntent?.listItem else { return false }
return !block[..
@@ -58,7 +58,9 @@ struct BlockTextViewSnapshotTests {
enum Appearance: String, CaseIterable {
case light, dark
- var colorScheme: ColorScheme { self == .light ? .light : .dark }
+ var colorScheme: ColorScheme {
+ self == .light ? .light : .dark
+ }
}
enum TextSize: String, CaseIterable {
@@ -101,7 +103,7 @@ struct BlockTextViewSnapshotTests {
Source.html.view,
appearance: appearance,
name: appearance.rawValue,
- testName: "appearance"
+ testName: "appearance",
)
}
@@ -111,7 +113,7 @@ struct BlockTextViewSnapshotTests {
source.view,
textSize: textSize,
name: "\(source.rawValue)-\(textSize.rawValue)",
- testName: "dynamicType"
+ testName: "dynamicType",
)
}
@@ -120,7 +122,7 @@ struct BlockTextViewSnapshotTests {
snapshot(
styling.apply(to: source.view),
name: "\(source.rawValue)-\(styling.rawValue)",
- testName: "topLevelTextModifiers"
+ testName: "topLevelTextModifiers",
)
}
@@ -130,7 +132,7 @@ struct BlockTextViewSnapshotTests {
source.view,
width: 220,
name: source.rawValue,
- testName: "narrowLayout"
+ testName: "narrowLayout",
)
}
@@ -142,7 +144,7 @@ struct BlockTextViewSnapshotTests {
name: String,
testName: String,
file: StaticString = #filePath,
- line: UInt = #line
+ line: UInt = #line,
) {
let view = content
.padding(16)
@@ -160,12 +162,12 @@ struct BlockTextViewSnapshotTests {
hostingView.layoutSubtreeIfNeeded()
assertSnapshot(
of: hostingView, as: .image, named: "macOS-\(name)",
- file: file, testName: testName, line: line
+ file: file, testName: testName, line: line,
)
#elseif os(iOS)
assertSnapshot(
of: view, as: .image(layout: .sizeThatFits, traits: UITraitCollection(displayScale: 2)),
- named: "iOS-\(name)", file: file, testName: testName, line: line
+ named: "iOS-\(name)", file: file, testName: testName, line: line,
)
#endif
}
diff --git a/Tests/SimpleHTMLTests/BlockTextViewTests.swift b/Tests/SimpleHTMLTests/BlockTextViewTests.swift
index 627c0d3..9b32a0d 100644
--- a/Tests/SimpleHTMLTests/BlockTextViewTests.swift
+++ b/Tests/SimpleHTMLTests/BlockTextViewTests.swift
@@ -1,8 +1,8 @@
import CustomDump
import Foundation
-@testable import SimpleHTML
import SwiftUI
import Testing
+@testable import SimpleHTML
@MainActor
struct BlockTextViewTests {
@@ -50,14 +50,14 @@ struct BlockTextViewTests {
let string = try AttributedString(markdown: "**Bold** and *italic* with [a link](https://example.com).\n\nNext")
let view = BlockTextView(attributedString: string)
let paragraph = try #require(view.blocks.first?.first)
- expectNoDifference(AttributedString(paragraph), AttributedString(string[string.startIndex..
A little emphasis.
A longer paragraph makes the centered alignment clear.
") - .font(.title3) - .multilineTextAlignment(.center), - name: "readme-alignment" - ) - } + @Test func readmeAlignment() { + snapshot( + HTMLView(html: "A little emphasis.
A longer paragraph makes the centered alignment clear.
") + .font(.title3) + .multilineTextAlignment(.center), + name: "readme-alignment", + ) + } - @Test func readmeMarkdown() throws { - let text = try AttributedString(markdown: """ + @Test func readmeMarkdown() throws { + let text = try AttributedString(markdown: """ A paragraph with **bold** text. 1. A first item 2. A second item - A nested bullet """) - snapshot(BlockTextView(attributedString: text), name: "readme-markdown") - } + snapshot(BlockTextView(attributedString: text), name: "readme-markdown") + } - @Test func overview() { - snapshot( - HTMLView(html: "Hello world.
Hello world.
A bold idea with a link.
Visit https://example.com/articles/long-title.
""") - snapshot(BlockTextView(attributedString: text), name: "visible-link") - } + snapshot(BlockTextView(attributedString: text), name: "visible-link") + } - @Test func customEmoji() throws { - func emojiText(html: String, images: [URL: Image]) -> Text { - let original = AttributedString(html: html) - var result = Text("") - for run in original.runs { - if let element = run.html.element, - element.name == "img", - element.has(class: "emoji"), - let url = run.imageURL, - let image = images[url] { - result = Text("\(result)\(Text(image).baselineOffset(-3))") - } else { - result = Text("\(result)\(Text(AttributedString(original[run.range])))") + @Test func customEmoji() throws { + func emojiText(html: String, images: [URL: Image]) -> Text { + let original = AttributedString(html: html) + var result = Text("") + for run in original.runs { + if let element = run.html.element, + element.name == "img", + element.has(class: "emoji"), + let url = run.imageURL, + let image = images[url] + { + result = Text("\(result)\(Text(image).baselineOffset(-3))") + } else { + result = Text("\(result)\(Text(AttributedString(original[run.range])))") + } } + return result } - return result - } - // Local copy of the documented URL keeps rendering deterministic and offline. - let imageURL = URL(string: "https://cdn.masto.host/tnkuco/custom_emojis/images/000/000/604/original/9a2294c91c6c88ed.png")! - let assetURL = try #require(Bundle.module.url(forResource: "custom-emoji", withExtension: "png")) - let asset = try #require(NSImage(contentsOf: assetURL)) - asset.size = CGSize(width: 20, height: 20) - let text = emojiText( - html: """ + // Local copy of the documented URL keeps rendering deterministic and offline. + let imageURL = URL(string: "https://cdn.masto.host/tnkuco/custom_emojis/images/000/000/604/original/9a2294c91c6c88ed.png")! + let assetURL = try #require(Bundle.module.url(forResource: "custom-emoji", withExtension: "png")) + let asset = try #require(NSImage(contentsOf: assetURL)) + asset.size = CGSize(width: 20, height: 20) + let text = emojiText( + html: """Nice work ![]()
Hello
@@ -442,7 +443,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { """) } - func testCondensesWhitespace() throws { + func testCondensesWhitespace() { let builder = HTMLAttributedStringBuilder(html: """\t Hello \n\t World \t \n\n
""") @@ -453,7 +454,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { """) } - func testTracksErrors() throws { + func testTracksErrors() { let builder = HTMLAttributedStringBuilder(html: """Hello World!
""") @@ -466,7 +467,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { XCTAssertFalse(builder.parseErrors.isEmpty) } - func testLineBreaks() throws { + func testLineBreaks() { let builder = HTMLAttributedStringBuilder(html: """How do I create a br?
is it like this?
Or maybe this is a p?
""") @@ -478,7 +479,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { """) } - func testPreserveWhitespace() throws { + func testPreserveWhitespace() { let builder = HTMLAttributedStringBuilder(html: """enum Delimiter {
case curlyBrace
}
Date: Wed, 14 Oct 2026 14:08:06 -0700
Subject: [PATCH 2/5] Add GitHub Actions tests, lint, and formatting
---
.github/workflows/swiftformat.yml | 34 +++++++++++++++++++
.github/workflows/swiftlint.yml | 25 ++++++++++++++
.github/workflows/tests.yml | 55 +++++++++++++++++++++++++++++++
.swiftlint.yml | 25 ++++++++++++++
CONTRIBUTING.md | 4 +++
Package.resolved | 20 ++++++++++-
Package.swift | 2 ++
bin/format | 5 +++
bin/lint | 5 +++
9 files changed, 174 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/swiftformat.yml
create mode 100644 .github/workflows/swiftlint.yml
create mode 100644 .github/workflows/tests.yml
create mode 100644 .swiftlint.yml
create mode 100755 bin/format
create mode 100755 bin/lint
diff --git a/.github/workflows/swiftformat.yml b/.github/workflows/swiftformat.yml
new file mode 100644
index 0000000..145ac06
--- /dev/null
+++ b/.github/workflows/swiftformat.yml
@@ -0,0 +1,34 @@
+name: swiftformat
+
+on:
+ push:
+ branches: ["**"]
+ paths:
+ - "**/*.swift"
+ - ".github/workflows/swiftformat.yml"
+ - ".swiftformat"
+ - "Package.resolved"
+ - "bin/format"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ format:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - run: bin/format
+
+ - uses: stefanzweifel/git-auto-commit-action@v7
+ with:
+ commit_message: format
+ file_pattern: '*.swift'
diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml
new file mode 100644
index 0000000..2612704
--- /dev/null
+++ b/.github/workflows/swiftlint.yml
@@ -0,0 +1,25 @@
+name: swiftlint
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - run: bin/lint
+
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..cd94c1d
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,55 @@
+name: tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ macos:
+ name: Test macOS
+ # The checked-in macOS snapshots were recorded on macOS 27.0.1.
+ runs-on: xcode-27
+ timeout-minutes: 30
+ env:
+ DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Run tests
+ run: swift test
+
+ ios:
+ name: Test iOS
+ runs-on: macos-26
+ timeout-minutes: 30
+ env:
+ DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Run tests
+ run: |
+ set -o pipefail
+ xcodebuild -scheme SimpleHTML \
+ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' \
+ -resultBundlePath .build/TestResults-iOS.xcresult \
+ test | xcbeautify --renderer github-actions
+
+ - name: Upload test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-ios
+ path: .build/TestResults-iOS.xcresult
+
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 0000000..93fc271
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,25 @@
+included:
+ - Package.swift
+ - Sources
+
+only_rules:
+ - custom_rules
+ - force_cast
+ - force_try
+ - force_unwrapping
+
+force_cast: error
+force_try: error
+force_unwrapping: error
+
+custom_rules:
+ unchecked_sendable:
+ name: "Unchecked Sendable"
+ regex: "@unchecked"
+ message: "Do not bypass Sendable checking with @unchecked."
+ severity: error
+ unsafe_nonisolated:
+ name: "Unsafe nonisolated"
+ regex: "nonisolated\\s*\\(\\s*unsafe\\s*\\)"
+ message: "Do not bypass actor isolation with nonisolated(unsafe)."
+ severity: error
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dce4311..77c19f5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,6 +10,10 @@ Use Xcode with a Swift 6.2 or newer toolchain. Open `Package.swift` in Xcode or
Keep parsing, attributed-string construction, and view rendering separate. Changes to supported HTML should include tests of the generated attributes; layout changes should be checked against HTML and Markdown inputs. Use Swift Testing for new tests. Do not use `.serialized` to work around flaky tests.
+Run `bin/format` and `bin/lint` before committing. Both use package plugins with pinned tool versions, so no separate formatter or linter installation is needed. SwiftFormat covers Swift sources and tests; SwiftLint checks the package manifest and production sources for forced casts, forced tries, forced unwraps, and unsafe concurrency overrides.
+
+GitHub Actions runs the full macOS test suite on the `xcode-27` preview runner, matching the macOS 27 snapshot baseline, and the full iOS suite on an iPhone 17 simulator running iOS 26.5 with Xcode 26.6. Tests and lint run for pull requests and pushes to `main`. A separate workflow formats branch pushes and commits any Swift formatting changes using the repository's default token. All three workflows also support manual runs.
+
## Running tests
Run the macOS tests with:
diff --git a/Package.resolved b/Package.resolved
index 3efde63..f14b61d 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "2400de3aa35137361436162ee10239598702356399e0738de9a00835f69fbe1e",
+ "originHash" : "871d8787232a9509f470eac5ce4cda8896391f2cd1c7e78670a8dbb3b0726bba",
"pins" : [
{
"identity" : "swift-custom-dump",
@@ -36,6 +36,24 @@
"revision" : "050f1a346fbbac0ca2cfb15a95274f7bd1cf0ccf",
"version" : "604.0.0"
}
+ },
+ {
+ "identity" : "swiftformat",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/nicklockwood/SwiftFormat",
+ "state" : {
+ "revision" : "2226e9d89bf05a604cc985bab3dccb44ff7c8ee3",
+ "version" : "0.62.1"
+ }
+ },
+ {
+ "identity" : "swiftlintplugins",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/SimplyDanny/SwiftLintPlugins.git",
+ "state" : {
+ "revision" : "3292bab28e6e92000161348003fdef8edc4e582d",
+ "version" : "0.65.0"
+ }
}
],
"version" : 3
diff --git a/Package.swift b/Package.swift
index 9c611e5..b3009df 100644
--- a/Package.swift
+++ b/Package.swift
@@ -19,6 +19,8 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.0"),
+ .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins.git", exact: "0.65.0"),
+ .package(url: "https://github.com/nicklockwood/SwiftFormat", exact: "0.62.1"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
diff --git a/bin/format b/bin/format
new file mode 100755
index 0000000..8eb1fd2
--- /dev/null
+++ b/bin/format
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+swift package plugin --allow-writing-to-package-directory swiftformat "$@"
diff --git a/bin/lint b/bin/lint
new file mode 100755
index 0000000..2f9ff51
--- /dev/null
+++ b/bin/lint
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+swift package plugin --allow-writing-to-package-directory swiftlint "$@"
From 6c9783f79c4fb1f1b5c288be88b8815277a31148 Mon Sep 17 00:00:00 2001
From: David Beck
Date: Wed, 14 Oct 2026 14:14:56 -0700
Subject: [PATCH 3/5] Make snapshot tests portable to hosted runners
---
.github/workflows/tests.yml | 15 ++++++++++++-
CONTRIBUTING.md | 7 ++++---
.../BlockTextViewSnapshotTests.swift | 21 ++++++++++++++++++-
3 files changed, 38 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index cd94c1d..b004f65 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -26,8 +26,19 @@ jobs:
- uses: actions/checkout@v7
- name: Run tests
+ env:
+ TMPDIR: ${{ runner.temp }}
run: swift test
+ - name: Upload failed snapshots
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: failed-snapshots-macos
+ path: |
+ ${{ runner.temp }}/BlockTextViewSnapshotTests
+ ${{ runner.temp }}/DocumentationSnapshotTests
+
ios:
name: Test iOS
runs-on: macos-26
@@ -39,16 +50,18 @@ jobs:
- uses: actions/checkout@v7
- name: Run tests
+ # Snapshot cases use SwiftUI view modifiers introduced in iOS 16.
run: |
set -o pipefail
xcodebuild -scheme SimpleHTML \
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' \
-resultBundlePath .build/TestResults-iOS.xcresult \
+ IPHONEOS_DEPLOYMENT_TARGET=16.0 \
test | xcbeautify --renderer github-actions
- name: Upload test results
if: failure()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: test-results-ios
path: .build/TestResults-iOS.xcresult
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 77c19f5..9872f11 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,16 +26,17 @@ Run the iOS tests with:
```sh
xcodebuild -scheme SimpleHTML \
- -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' test
+ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' \
+ IPHONEOS_DEPLOYMENT_TARGET=16.0 test
```
-The test suite includes parsing and whitespace tests, block-layout tests, and image snapshots. Snapshot rendering depends on OS fonts, so use the baseline platform versions when comparing images.
+The test suite includes parsing and whitespace tests, block-layout tests, and image snapshots. The iOS test build targets iOS 16 because the snapshot cases exercise SwiftUI's view-level bold and italic modifiers. The library continues to support iOS 15. Snapshot rendering depends on OS fonts, so use the baseline platform versions when comparing images.
## Snapshot tests
`BlockTextViewSnapshotTests` records separate macOS and iOS images for both HTML and Markdown. One light/dark pair covers appearance; the remaining cases run in light mode. Cases cover small, large, XXXL, and accessibility Dynamic Type sizes; font, bold/italic, monospaced text, foreground color, tint, line spacing, and multiline alignment; and a narrow layout. Fixtures include wrapped text, links, three levels of lists, continuation paragraphs, and repeated content.
-The iOS references were recorded on an iPhone 17 simulator running iOS 26.5 at a fixed 2× display scale. The macOS references were recorded on macOS 27.0.1. Dynamic Type sizes affect the iOS references; macOS currently keeps the same text size.
+The iOS references were recorded on an iPhone 17 simulator running iOS 26.5 at a fixed 2× display scale. The macOS references were recorded on macOS 27.0.1, also at 2×. The macOS helper creates a bitmap at that scale so tests do not depend on whether the machine has a Retina display. Dynamic Type sizes affect the iOS references; macOS currently keeps the same text size.
To intentionally update the baselines:
diff --git a/Tests/SimpleHTMLTests/BlockTextViewSnapshotTests.swift b/Tests/SimpleHTMLTests/BlockTextViewSnapshotTests.swift
index de05904..dc383a5 100644
--- a/Tests/SimpleHTMLTests/BlockTextViewSnapshotTests.swift
+++ b/Tests/SimpleHTMLTests/BlockTextViewSnapshotTests.swift
@@ -160,8 +160,27 @@ struct BlockTextViewSnapshotTests {
hostingView.appearance = NSAppearance(named: appearance == .light ? .aqua : .darkAqua)
hostingView.frame.size = hostingView.fittingSize
hostingView.layoutSubtreeIfNeeded()
+ // The references use 2× pixels, regardless of the machine's display scale.
+ let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: Int(hostingView.bounds.width * 2),
+ pixelsHigh: Int(hostingView.bounds.height * 2),
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .calibratedRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0,
+ )!
+ bitmap.size = hostingView.bounds.size
+ hostingView.cacheDisplay(in: hostingView.bounds, to: bitmap)
+ let image = NSImage(
+ cgImage: bitmap.cgImage!,
+ size: CGSize(width: bitmap.pixelsWide, height: bitmap.pixelsHigh),
+ )
assertSnapshot(
- of: hostingView, as: .image, named: "macOS-\(name)",
+ of: image, as: .image, named: "macOS-\(name)",
file: file, testName: testName, line: line,
)
#elseif os(iOS)
From a7dc1a53dfed42eb299a908f617e2ef88cf65ee5 Mon Sep 17 00:00:00 2001
From: David Beck
Date: Wed, 14 Oct 2026 14:19:30 -0700
Subject: [PATCH 4/5] Use SnapshotTesting artifact directory setting
---
.github/workflows/tests.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index b004f65..ffa86a9 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -27,7 +27,7 @@ jobs:
- name: Run tests
env:
- TMPDIR: ${{ runner.temp }}
+ SNAPSHOT_ARTIFACTS: ${{ runner.temp }}
run: swift test
- name: Upload failed snapshots
From f92ef29f857ebc97245f6edade7d98f867691157 Mon Sep 17 00:00:00 2001
From: David Beck
Date: Wed, 14 Oct 2026 14:23:40 -0700
Subject: [PATCH 5/5] Allow documented color rounding in documentation
snapshots
---
CONTRIBUTING.md | 2 ++
Tests/SimpleHTMLTests/DocumentationSnapshotTests.swift | 4 +++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9872f11..996b0bf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -53,6 +53,8 @@ Do not regenerate snapshots just to make an unexplained failure pass. Investigat
The suite renders content-sized canvases rendered directly by SwiftUI at 4× pixel density with a 16-point margin, a white background, a thin gray border, and fixed light appearance and locale. References were recorded on macOS 27.0.1. The suite's PNGs in `Sources/SimpleHTML/SimpleHTML.docc/Resources/` are both the snapshot baselines and the published assets; README links to the same files.
+Documentation images use a perceptual color precision of 0.99 while still requiring every pixel to match. Local and hosted rendering can differ by one value in a few 8-bit color channels during conversion to sRGB. Behavior snapshots retain exact pixel comparisons.
+
To regenerate them, temporarily set this suite's trait to `.snapshots(record: .all)`, then run:
```sh
diff --git a/Tests/SimpleHTMLTests/DocumentationSnapshotTests.swift b/Tests/SimpleHTMLTests/DocumentationSnapshotTests.swift
index 259e502..a17aa03 100644
--- a/Tests/SimpleHTMLTests/DocumentationSnapshotTests.swift
+++ b/Tests/SimpleHTMLTests/DocumentationSnapshotTests.swift
@@ -168,8 +168,10 @@
let directory = URL(fileURLWithPath: "\(file)")
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
.appendingPathComponent("Sources/SimpleHTML/SimpleHTML.docc/Resources")
+ // ImageRenderer's color conversion can round a channel by one byte across
+ // graphics backends. Require every pixel to match within a small color delta.
if let failure = verifySnapshot(
- of: image, as: .image, named: name,
+ of: image, as: .image(perceptualPrecision: 0.99), named: name,
snapshotDirectory: directory.path,
file: file, testName: "documentation", line: line,
) {