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..ffa86a9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,68 @@ +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 + env: + SNAPSHOT_ARTIFACTS: ${{ 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 + timeout-minutes: 30 + env: + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + + steps: + - 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@v7 + with: + name: test-results-ios + path: .build/TestResults-iOS.xcresult + 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/.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..996b0bf 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: @@ -22,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: @@ -48,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/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/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[.. Int { @@ -199,7 +201,7 @@ public class HTMLAttributedStringBuilder { private func nextOrdinal(for parent: PresentationIntent?) -> Int { let ordinal: Int - if let parent = parent { + if let parent { ordinal = ordinalStack[parent, default: 1] ordinalStack[parent] = ordinal + 1 } else { @@ -214,7 +216,7 @@ extension HTMLAttributedStringBuilder: HTMLParserDelegate { public func parser( _ parser: HTMLParser, didStartElement elementName: String, - attributes attributeDict: [AnyHashable: Any] + attributes attributeDict: [AnyHashable: Any], ) { var attributes = currentAttributes let parent = attributes.presentationIntent @@ -222,7 +224,7 @@ extension HTMLAttributedStringBuilder: HTMLParserDelegate { let element = HTMLElement( parent: attributes.html.element, name: elementName, - attributes: attributeDict as? [String: String] ?? [:] + attributes: attributeDict as? [String: String] ?? [:], ) attributes.html.element = element @@ -244,25 +246,25 @@ extension HTMLAttributedStringBuilder: HTMLParserDelegate { attributes.presentationIntent = .init( .paragraph, identity: nextIntentID(), - parent: parent + parent: parent, ) case "ul": attributes.presentationIntent = .init( .unorderedList, identity: nextIntentID(), - parent: parent + parent: parent, ) case "ol": attributes.presentationIntent = .init( .orderedList, identity: nextIntentID(), - parent: parent + parent: parent, ) case "li": attributes.presentationIntent = .init( .listItem(ordinal: nextOrdinal(for: parent)), identity: nextIntentID(), - parent: parent + parent: parent, ) case "img": let urlString = attributeDict["src"] as? String ?? "" @@ -284,14 +286,14 @@ extension HTMLAttributedStringBuilder: HTMLParserDelegate { public func parser( _ parser: HTMLParser, - didEndElement elementName: String + didEndElement elementName: String, ) { attributesStack.removeLast() } public func parser( _ parser: HTMLParser, - foundCharacters string: String + foundCharacters string: String, ) { let container = currentAttributes let new = AttributedString(string, attributes: container) @@ -302,7 +304,7 @@ extension HTMLAttributedStringBuilder: HTMLParserDelegate { public func parserFoundProcessingInstruction( _ parser: HTMLParser, target: String, - data: String? + data: String?, ) {} public func parserDidEndDocument(_ parser: HTMLParser) { @@ -330,7 +332,7 @@ extension HTMLAttributedStringBuilder: HTMLParserDelegate { public func parser( _ parser: HTMLParser, - parseErrorOccurred parseError: ParseError + parseErrorOccurred parseError: ParseError, ) { // errors can be pretty mundane and not actually cause issues self.parseErrors.append(parseError) diff --git a/Sources/SimpleHTML/HTMLElement.swift b/Sources/SimpleHTML/HTMLElement.swift index 1284673..7e2894a 100644 --- a/Sources/SimpleHTML/HTMLElement.swift +++ b/Sources/SimpleHTML/HTMLElement.swift @@ -13,7 +13,7 @@ public struct HTMLElement: Sendable { init( parent: HTMLElement? = nil, name: String, - attributes: [String: String] = [:] + attributes: [String: String] = [:], ) { self.parent = parent self.name = name @@ -46,12 +46,12 @@ public struct HTMLElement: Sendable { public init( parent: HTMLElement?, name: String, - attributes: [String: String] + attributes: [String: String], ) { storage = Storage( parent: parent, name: name, - attributes: attributes + attributes: attributes, ) } @@ -115,7 +115,7 @@ extension HTMLElement: Codable { try self.init( parent: container.decodeIfPresent(HTMLElement.self, forKey: .parent), name: container.decode(String.self, forKey: .name), - attributes: container.decode([String: String].self, forKey: .attributes) + attributes: container.decode([String: String].self, forKey: .attributes), ) } diff --git a/Sources/SimpleHTML/HTMLParser.swift b/Sources/SimpleHTML/HTMLParser.swift index aecb2b1..39b3d22 100644 --- a/Sources/SimpleHTML/HTMLParser.swift +++ b/Sources/SimpleHTML/HTMLParser.swift @@ -29,38 +29,38 @@ public protocol HTMLParserDelegate: AnyObject { func parser( _ parser: HTMLParser, didStartElement elementName: String, - attributes attributeDict: [AnyHashable: Any] + attributes attributeDict: [AnyHashable: Any], ) /// Reports a closing element after libxml2 recovery. func parser( _ parser: HTMLParser, - didEndElement elementName: String + didEndElement elementName: String, ) /// Reports decoded character data, including expanded entities. func parser( _ parser: HTMLParser, - foundCharacters string: String + foundCharacters string: String, ) /// Reports a comment without its markup delimiters. func parser( _ parser: HTMLParser, - foundComment comment: String + foundComment comment: String, ) /// Reports a CDATA block. func parser( _ parser: HTMLParser, - foundCDATA cdata: Data + foundCDATA cdata: Data, ) /// Reports a processing instruction and its optional data. func parserFoundProcessingInstruction( _ parser: HTMLParser, target: String, - data: String? + data: String?, ) /// Called when the document ends. @@ -69,7 +69,7 @@ public protocol HTMLParserDelegate: AnyObject { /// Reports a parser diagnostic; recovery may continue. func parser( _ parser: HTMLParser, - parseErrorOccurred parseError: ParseError + parseErrorOccurred parseError: ParseError, ) } @@ -80,12 +80,12 @@ public extension HTMLParserDelegate { func parser( _ parser: HTMLParser, - foundComment comment: String + foundComment comment: String, ) {} func parser( _ parser: HTMLParser, - foundCDATA cdata: Data + foundCDATA cdata: Data, ) {} } @@ -300,7 +300,7 @@ public class HTMLParser: SAXErrorHandler { buffer.bindMemory(to: CChar.self).baseAddress, Int32(data.count), nil, - charEnc + charEnc, ) } @@ -309,7 +309,7 @@ public class HTMLParser: SAXErrorHandler { HTML_PARSE_RECOVER.rawValue | HTML_PARSE_NONET.rawValue | HTML_PARSE_COMPACT.rawValue | - HTML_PARSE_NOBLANKS.rawValue + HTML_PARSE_NOBLANKS.rawValue, ) htmlCtxtUseOptions(_parserContext, Int32(bitPattern: options.rawValue)) diff --git a/Sources/SimpleHTML/HTMLView.swift b/Sources/SimpleHTML/HTMLView.swift index 6a67568..06e0ffd 100644 --- a/Sources/SimpleHTML/HTMLView.swift +++ b/Sources/SimpleHTML/HTMLView.swift @@ -61,7 +61,7 @@ public struct HTMLView: View { /// - content: A view builder receiving the parsed string. Use ``BlockTextView`` to retain block layout. public init( html: String, - @ViewBuilder content: @escaping (AttributedString) -> Content + @ViewBuilder content: @escaping (AttributedString) -> Content, ) { self.html = html self.content = content diff --git a/Sources/SimpleHTML/String+AlphaNumerals.swift b/Sources/SimpleHTML/String+AlphaNumerals.swift index 0a7e5c4..c3acea3 100644 --- a/Sources/SimpleHTML/String+AlphaNumerals.swift +++ b/Sources/SimpleHTML/String+AlphaNumerals.swift @@ -15,7 +15,7 @@ extension String { while number > 0 { self.insert( characters[number % characters.count], - at: self.startIndex + at: self.startIndex, ) number = (number - 1) / characters.count } diff --git a/Tests/SimpleHTMLTests/BlockTextViewSnapshotTests.swift b/Tests/SimpleHTMLTests/BlockTextViewSnapshotTests.swift index 600ea70..dc383a5 100644 --- a/Tests/SimpleHTMLTests/BlockTextViewSnapshotTests.swift +++ b/Tests/SimpleHTMLTests/BlockTextViewSnapshotTests.swift @@ -23,8 +23,8 @@ struct BlockTextViewSnapshotTests { } } - // Equivalent content exercises wrapping, inline styles, three nesting levels, - // continuation paragraphs, and repeated paragraphs/items. + /// Equivalent content exercises wrapping, inline styles, three nesting levels, + /// continuation paragraphs, and repeated paragraphs/items. private static let htmlFixture = """

A paragraph with bold, italic, and a link that wraps onto another line.

  1. First ordered item

    Continuation of the same item.

    1. Nested ordered item
      1. Third level
  2. Second ordered item
@@ -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) @@ -158,14 +160,33 @@ 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)", - file: file, testName: testName, line: line + of: image, as: .image, named: "macOS-\(name)", + 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..Hello world. Here's what we support:

  • Inline formatting and links
  • Paragraphs and nested lists
""") .font(.body) .tint(.purple), - name: "readme-introduction" - ) - } + name: "readme-introduction", + ) + } - @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 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.

  • A list item
"), - name: "overview" - ) - } + @Test func overview() { + snapshot( + HTMLView(html: "

Hello world.

  • A list item
"), + name: "overview", + ) + } - @Test func htmlView() { - snapshot( - HTMLView(html: """ + @Test func htmlView() { + snapshot( + HTMLView(html: """

A bold idea with a link.

  1. Start here
  2. Keep going
""") .font(.body) .tint(.orange) .lineSpacing(4), - name: "html-view" - ) - } + name: "html-view", + ) + } - @Test func blockTextView() throws { - let text = try AttributedString(markdown: """ + @Test func blockTextView() throws { + let text = try AttributedString(markdown: """ A paragraph with **bold** and *italic* text. 1. First item @@ -73,107 +73,110 @@ struct DocumentationSnapshotTests { - A nested bullet 2. Second item """) - snapshot( - BlockTextView(attributedString: text) - .font(.body) - .multilineTextAlignment(.leading), - name: "block-text-view" - ) - } + snapshot( + BlockTextView(attributedString: text) + .font(.body) + .multilineTextAlignment(.leading), + name: "block-text-view", + ) + } - @Test func visibleLink() { - func visibleText(html: String) -> AttributedString { - let original = AttributedString(html: html, preserveWhitespace: true) - var result = AttributedString() - for run in original.runs { - let element = run.html.element - if element?.has(class: "invisible") == true { - continue - } - result.append(original[run.range]) - if element?.has(class: "ellipsis") == true { - result.append(AttributedString("…", attributes: run.attributes)) + @Test func visibleLink() { + func visibleText(html: String) -> AttributedString { + let original = AttributedString(html: html, preserveWhitespace: true) + var result = AttributedString() + for run in original.runs { + let element = run.html.element + if element?.has(class: "invisible") == true { + continue + } + result.append(original[run.range]) + if element?.has(class: "ellipsis") == true { + result.append(AttributedString("…", attributes: run.attributes)) + } } + return result } - return result - } - let text = visibleText(html: """ + let text = visibleText(html: """

Visit example.com/articles.

""") - 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 :swift:

""", - images: [imageURL: Image(nsImage: asset)] - ) - snapshot(text, name: "custom-emoji") - } + images: [imageURL: Image(nsImage: asset)], + ) + snapshot(text, name: "custom-emoji") + } - private func snapshot( - _ content: some View, - name: String, - file: StaticString = #filePath, - line: UInt = #line - ) { - // Fit each example to its content, with a modest margin like Apple’s documentation illustrations. - let view = content - .font(.body) - .foregroundColor(.black) - .fixedSize(horizontal: true, vertical: true) - .padding(16) - .background(Color.white) - .overlay(Rectangle().strokeBorder(Color(white: 0.8), lineWidth: 1)) - .environment(\.colorScheme, .light) - .environment(\.dynamicTypeSize, .large) - .environment(\.locale, Locale(identifier: "en_US")) - // Render SwiftUI directly at 4× so text is rasterized at the output density. - let renderer = ImageRenderer(content: view) - renderer.scale = 4 - // Preserve the full bitmap when SnapshotTesting asks AppKit for a CGImage. - let bitmap = renderer.cgImage! - let image = NSImage( - cgImage: bitmap, - size: CGSize(width: bitmap.width, height: bitmap.height) - ) - - // DocC resources are also the references: no copied images to drift out of sync. - let directory = URL(fileURLWithPath: "\(file)") - .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() - .appendingPathComponent("Sources/SimpleHTML/SimpleHTML.docc/Resources") - if let failure = verifySnapshot( - of: image, as: .image, named: name, - snapshotDirectory: directory.path, - file: file, testName: "documentation", line: line + private func snapshot( + _ content: some View, + name: String, + file: StaticString = #filePath, + line: UInt = #line, ) { - Issue.record("\(failure)") + // Fit each example to its content, with a modest margin like Apple’s documentation illustrations. + let view = content + .font(.body) + .foregroundColor(.black) + .fixedSize(horizontal: true, vertical: true) + .padding(16) + .background(Color.white) + .overlay(Rectangle().strokeBorder(Color(white: 0.8), lineWidth: 1)) + .environment(\.colorScheme, .light) + .environment(\.dynamicTypeSize, .large) + .environment(\.locale, Locale(identifier: "en_US")) + // Render SwiftUI directly at 4× so text is rasterized at the output density. + let renderer = ImageRenderer(content: view) + renderer.scale = 4 + // Preserve the full bitmap when SnapshotTesting asks AppKit for a CGImage. + let bitmap = renderer.cgImage! + let image = NSImage( + cgImage: bitmap, + size: CGSize(width: bitmap.width, height: bitmap.height), + ) + + // DocC resources are also the references: no copied images to drift out of sync. + 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(perceptualPrecision: 0.99), named: name, + snapshotDirectory: directory.path, + file: file, testName: "documentation", line: line, + ) { + Issue.record("\(failure)") + } } } -} #endif diff --git a/Tests/SimpleHTMLTests/HTMLAttributedStringBuilderTests.swift b/Tests/SimpleHTMLTests/HTMLAttributedStringBuilderTests.swift index 241812d..01e4ec1 100644 --- a/Tests/SimpleHTMLTests/HTMLAttributedStringBuilderTests.swift +++ b/Tests/SimpleHTMLTests/HTMLAttributedStringBuilderTests.swift @@ -37,17 +37,18 @@ func XCTRequireEqual( _ expression2: T, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, - line: UInt = #line + line: UInt = #line, ) throws { if expression1 != expression2 { let m = message() throw TestRequireFailure( - message: m.isEmpty ? "\(expression1) != \(expression2)" : m) + message: m.isEmpty ? "\(expression1) != \(expression2)" : m, + ) } } class HTMLAttributedStringBuilderTests: XCTestCase { - func testBasicString() throws { + func testBasicString() { let builder = HTMLAttributedStringBuilder(html: "Hello World!") let string = builder.generatedAttributedString() @@ -56,7 +57,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { func testStrongText() throws { let builder = HTMLAttributedStringBuilder( - html: "Hello World!" + html: "Hello World!", ) let string = builder.generatedAttributedString() @@ -69,7 +70,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { XCTAssertEqual(string[runs[1].range].string, "World") XCTAssertEqual( runs[1].attributes.inlinePresentationIntent, - .stronglyEmphasized + .stronglyEmphasized, ) XCTAssertEqual(string[runs[2].range].string, "!") @@ -78,7 +79,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { func testBText() throws { let builder = HTMLAttributedStringBuilder( - html: "Hello World!" + html: "Hello World!", ) let string = builder.generatedAttributedString() @@ -91,7 +92,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { XCTAssertEqual(string[runs[1].range].string, "World") XCTAssertEqual( runs[1].attributes.inlinePresentationIntent, - .stronglyEmphasized + .stronglyEmphasized, ) XCTAssertEqual(string[runs[2].range].string, "!") @@ -100,7 +101,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { func testEmText() throws { let builder = HTMLAttributedStringBuilder( - html: "Hello World!" + html: "Hello World!", ) let string = builder.generatedAttributedString() @@ -113,7 +114,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { XCTAssertEqual(string[runs[1].range].string, "World") XCTAssertEqual( runs[1].attributes.inlinePresentationIntent, - .emphasized + .emphasized, ) XCTAssertEqual(string[runs[2].range].string, "!") @@ -122,7 +123,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { func testIText() throws { let builder = HTMLAttributedStringBuilder( - html: "Hello World!" + html: "Hello World!", ) let string = builder.generatedAttributedString() @@ -135,7 +136,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { XCTAssertEqual(string[runs[1].range].string, "World") XCTAssertEqual( runs[1].attributes.inlinePresentationIntent, - .emphasized + .emphasized, ) XCTAssertEqual(string[runs[2].range].string, "!") @@ -144,7 +145,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { func testUnderlinedText() throws { let builder = HTMLAttributedStringBuilder( - html: "Hello World!" + html: "Hello World!", ) let string = builder.generatedAttributedString() @@ -163,7 +164,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { func testLinks() throws { let builder = HTMLAttributedStringBuilder( - html: #"Hello World!"# + html: #"Hello World!"#, ) let string = builder.generatedAttributedString() @@ -176,7 +177,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { XCTAssertEqual(string[runs[1].range].string, "World") XCTAssertEqual( runs[1].attributes.link, - try XCTUnwrap(URL(string: "https://www.acstechnologies.com/realm/")) + try XCTUnwrap(URL(string: "https://www.acstechnologies.com/realm/")), ) XCTAssertEqual(string[runs[2].range].string, "!") @@ -185,7 +186,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { func testOverlapingAttributes() throws { let builder = HTMLAttributedStringBuilder( - html: "Hello World!" + html: "Hello World!", ) let string = builder.generatedAttributedString() @@ -198,7 +199,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { XCTAssertEqual(string[runs[1].range].string, "World") XCTAssertEqual( runs[1].attributes.inlinePresentationIntent, - [.emphasized, .stronglyEmphasized] + [.emphasized, .stronglyEmphasized], ) XCTAssertEqual(string[runs[2].range].string, "!") @@ -259,8 +260,8 @@ class HTMLAttributedStringBuilderTests: XCTestCase { .init( .paragraph, identity: 1, - parent: nil - ) + parent: nil, + ), ) } @@ -271,8 +272,8 @@ class HTMLAttributedStringBuilderTests: XCTestCase { .init( .paragraph, identity: 2, - parent: nil - ) + parent: nil, + ), ) } } @@ -425,7 +426,7 @@ class HTMLAttributedStringBuilderTests: XCTestCase { } } - func testIgnoresWhitespace() throws { + func testIgnoresWhitespace() { let builder = HTMLAttributedStringBuilder(html: """

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
}

""", preserveWhitespace: true) diff --git a/Tests/SimpleHTMLTests/HTMLElementTests.swift b/Tests/SimpleHTMLTests/HTMLElementTests.swift index fe64e1e..9703b84 100644 --- a/Tests/SimpleHTMLTests/HTMLElementTests.swift +++ b/Tests/SimpleHTMLTests/HTMLElementTests.swift @@ -14,7 +14,7 @@ struct HTMLElementTests { @Test(arguments: [nil, "", " \t\n\r "] as [String?]) func emptyClassAttributes(value: String?) { let element = HTMLElement( - parent: nil, name: "span", attributes: value.map { ["class": $0] } ?? [:] + parent: nil, name: "span", attributes: value.map { ["class": $0] } ?? [:], ) expectNoDifference(element.classes, []) #expect(!element.has(class: "")) 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 "$@"