Commit a5dc89631c
a5dc89631cf65a183edae1bfa22ef147bd8f1686
parent: 6e2ef80a16
Verified · cmc
cmc <hello@cleberg.net> · 2026-08-31 23:06 UTC
Render a README's badge row as badges
A paragraph holding nothing but images and image links is the badge row
a README opens with. It became prose: the inline styler can only show an
image's alt text, so `[[builds][file:badge.svg]]` rendered as the badge
URL in italics.
Such a paragraph is now a `.badges` block — a row of images at their own
size, each tapping through to its link. Natural size, not a figure: a
figure fills the column, which would blow a 95x20 badge up to a
pixelated bar. A lone image on its own line is still a figure, and prose
anywhere in the paragraph still makes it prose.
Layout: unified · split
Sources/OrgSwiftUI/OrgBadgeRowView.swift
added
+49
| @@ -0,0 +1,49 @@ |
| 1 | import SwiftUI |
| 2 | |
| 3 | /// The row of status badges a README opens with. |
| 4 | /// |
| 5 | /// Badges keep their own size — a badge is authored at the pixel size it is meant to be |
| 6 | /// seen at, so `resizable()` would only blur it. They wrap to the next line when the row is |
| 7 | /// wider than the column, which is what a long badge row does on the web. |
| 8 | struct OrgBadgeRowView: View { |
| 9 | let badges: [OrgBadge] |
| 10 | |
| 11 | /// The scale badge artwork is served at. Retina artwork is the only way a small raster |
| 12 | /// badge stays sharp, and a badge has no other channel to declare its scale. |
| 13 | static let scale: CGFloat = 2 |
| 14 | |
| 15 | var body: some View { |
| 16 | // `ViewThatFits` is not a wrap; `Layout` is more than a badge row needs. A flexible |
| 17 | // `HStack` inside a horizontal `ScrollView` keeps a long row reachable without |
| 18 | // guessing at line breaks. |
| 19 | ScrollView(.horizontal, showsIndicators: false) { |
| 20 | HStack(spacing: 6) { |
| 21 | ForEach(badges) { badge in |
| 22 | link(badge) { |
| 23 | AsyncImage(url: badge.source, scale: Self.scale) { phase in |
| 24 | switch phase { |
| 25 | case .success(let image): |
| 26 | image |
| 27 | case .failure: |
| 28 | Text(badge.alt).font(.footnote).foregroundStyle(.secondary) |
| 29 | default: |
| 30 | ProgressView().controlSize(.mini) |
| 31 | } |
| 32 | } |
| 33 | .accessibilityLabel(badge.alt) |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | /// A badge with no resolvable link is still shown, just not tappable. |
| 41 | @ViewBuilder |
| 42 | private func link(_ badge: OrgBadge, @ViewBuilder content: () -> some View) -> some View { |
| 43 | if let url = badge.link { |
| 44 | Link(destination: url, label: content) |
| 45 | } else { |
| 46 | content() |
| 47 | } |
| 48 | } |
| 49 | } |
Sources/OrgSwiftUI/OrgBlockView.swift
+3
| @@ -93,6 +93,9 @@ struct OrgBlockView: View { |
| 93 | 93 | } |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | case .badges(let badges): |
| 97 | OrgBadgeRowView(badges: badges) |
| 98 | |
| 96 | 99 | case .captioned(let caption, let blocks): |
| 97 | 100 | VStack(alignment: .leading, spacing: 4) { |
| 98 | 101 | OrgBlockListView(blocks: blocks) |
Sources/OrgSwiftUI/OrgLayout.swift
+9
| @@ -32,6 +32,7 @@ indirect enum OrgBlock: Identifiable { |
| 32 | 32 | case list(OrgListLayout) |
| 33 | 33 | case table(OrgTableLayout) |
| 34 | 34 | case image(source: URL, alt: String, caption: AttributedString?) |
| 35 | case badges([OrgBadge]) |
| 35 | 36 | case captioned(caption: AttributedString?, blocks: [OrgBlock]) |
| 36 | 37 | case rule |
| 37 | 38 | |
| @@ -40,6 +41,14 @@ indirect enum OrgBlock: Identifiable { |
| 40 | 41 | var id: String { String(describing: self).prefix(64).description } |
| 41 | 42 | } |
| 42 | 43 | |
| 44 | /// One image in a badge row, with the link it stands for when it has one. |
| 45 | struct OrgBadge: Identifiable { |
| 46 | var source: URL |
| 47 | var alt: String |
| 48 | var link: URL? |
| 49 | var id: URL { source } |
| 50 | } |
| 51 | |
| 43 | 52 | struct OrgListLayout { |
| 44 | 53 | var kind: OrgListKind |
| 45 | 54 | var items: [Item] |
Sources/OrgSwiftUI/OrgLayoutBuilder.swift
+39
| @@ -62,6 +62,7 @@ struct OrgLayoutBuilder { |
| 62 | 62 | return [.heading(text: title, level: level)] |
| 63 | 63 | |
| 64 | 64 | case .paragraph(let objects): |
| 65 | if let badges = badgeRow(objects) { return [.badges(badges)] } |
| 65 | 66 | return [.paragraph(text(objects, ¬es))] |
| 66 | 67 | |
| 67 | 68 | case .list(let list): |
| @@ -110,6 +111,44 @@ struct OrgLayoutBuilder { |
| 110 | 111 | } |
| 111 | 112 | } |
| 112 | 113 | |
| 114 | /// A paragraph holding nothing but images and image links is the badge row a README |
| 115 | /// opens with. It renders as a row of images at their own size rather than as prose — |
| 116 | /// the inline styler can only show an image's alt text, and a figure block would blow a |
| 117 | /// 95×20 badge up to the column width. |
| 118 | /// |
| 119 | /// Anything else in the paragraph, a word of prose included, means it is prose. |
| 120 | private func badgeRow(_ objects: [OrgObject]) -> [OrgBadge]? { |
| 121 | var badges: [OrgBadge] = [] |
| 122 | for object in objects { |
| 123 | switch object { |
| 124 | case .text(let string) where string.trimmingCharacters(in: .whitespaces).isEmpty: |
| 125 | continue |
| 126 | case .image(let figure): |
| 127 | guard let badge = badge(figure, link: nil) else { return nil } |
| 128 | badges.append(badge) |
| 129 | case .link(let link): |
| 130 | guard link.description?.count == 1, |
| 131 | case .image(let figure) = link.description?.first |
| 132 | else { return nil } |
| 133 | // A badge whose link will not resolve is still a badge; it just is not |
| 134 | // tappable. |
| 135 | let href = resolver.href(for: link.target).flatMap { URL(string: $0) } |
| 136 | guard let badge = badge(figure, link: href) else { return nil } |
| 137 | badges.append(badge) |
| 138 | default: |
| 139 | return nil |
| 140 | } |
| 141 | } |
| 142 | return badges.isEmpty ? nil : badges |
| 143 | } |
| 144 | |
| 145 | private func badge(_ figure: OrgFigure, link: URL?) -> OrgBadge? { |
| 146 | guard let string = resolver.imageSource(figure.source), |
| 147 | let url = URL(string: string), url.scheme != nil |
| 148 | else { return nil } |
| 149 | return OrgBadge(source: url, alt: figure.alt ?? "", link: link) |
| 150 | } |
| 151 | |
| 113 | 152 | /// An image whose source will not resolve to a safe absolute URL degrades to its alt |
| 114 | 153 | /// text, the same trade the HTML renderer makes rather than pointing at something unsafe. |
| 115 | 154 | /// |
Tests/OrgSwiftUITests/OrgLayoutTests.swift
+71
| @@ -240,3 +240,74 @@ struct OrgLayoutTests { |
| 240 | 240 | #expect(fonts.count >= 3) |
| 241 | 241 | } |
| 242 | 242 | } |
| 243 | |
| 244 | /// The badge row: a paragraph of nothing but images and image links. |
| 245 | @MainActor |
| 246 | struct OrgBadgeRowTests { |
| 247 | |
| 248 | private func blocks(_ source: String) -> [OrgBlock] { |
| 249 | OrgLayoutBuilder(options: .init(), styler: PlainOrgCodeStyler(), keywords: .init()) |
| 250 | .layout(OrgParser.parse(source)) |
| 251 | .blocks |
| 252 | } |
| 253 | |
| 254 | private func badges(_ source: String) -> [OrgBadge]? { |
| 255 | guard case .badges(let badges) = blocks(source).first else { return nil } |
| 256 | return badges |
| 257 | } |
| 258 | |
| 259 | @Test |
| 260 | func aLinkWrappingAnImageIsABadge() throws { |
| 261 | let row = try #require(badges( |
| 262 | "[[https://gitbay.org/krz/gitbay/builds][file:https://gitbay.org/krz/gitbay/badge/build.svg]]" |
| 263 | )) |
| 264 | #expect(row.count == 1) |
| 265 | #expect(row[0].source.absoluteString == "https://gitbay.org/krz/gitbay/badge/build.svg") |
| 266 | #expect(row[0].link?.absoluteString == "https://gitbay.org/krz/gitbay/builds") |
| 267 | } |
| 268 | |
| 269 | @Test |
| 270 | func severalBadgesOnOneLineAreOneRow() throws { |
| 271 | let row = try #require(badges(""" |
| 272 | [[https://example.com/a][file:https://example.com/a.png]] \ |
| 273 | [[https://example.com/b][file:https://example.com/b.png]] |
| 274 | """)) |
| 275 | #expect(row.count == 2) |
| 276 | } |
| 277 | |
| 278 | @Test |
| 279 | func anUnlinkedImageIsStillABadgeWhenItSharesTheLine() throws { |
| 280 | let row = try #require(badges( |
| 281 | "[[https://example.com/a][file:https://example.com/a.png]] [[file:https://example.com/b.png]]" |
| 282 | )) |
| 283 | #expect(row.count == 2) |
| 284 | #expect(row[1].link == nil) |
| 285 | } |
| 286 | |
| 287 | @Test |
| 288 | func aParagraphWithProseIsNotABadgeRow() { |
| 289 | #expect(badges( |
| 290 | "Build: [[https://example.com/a][file:https://example.com/a.png]]" |
| 291 | ) == nil) |
| 292 | } |
| 293 | |
| 294 | @Test |
| 295 | func aLinkWithATextDescriptionIsNotABadgeRow() { |
| 296 | #expect(badges("[[https://example.com][the builds]]") == nil) |
| 297 | } |
| 298 | |
| 299 | /// A lone image on its own line is already a figure, and stays one: a figure carries a |
| 300 | /// caption and fills the column, which is right for a diagram and wrong for a badge. |
| 301 | @Test |
| 302 | func aStandaloneImageStaysAFigure() { |
| 303 | if case .image = blocks("[[file:https://example.com/diagram.png]]").first { return } |
| 304 | Issue.record("expected a figure block") |
| 305 | } |
| 306 | |
| 307 | /// A relative source with no repository to resolve against cannot be made absolute, and |
| 308 | /// a badge has no alt-text fallback to degrade to, so the paragraph stays prose. |
| 309 | @Test |
| 310 | func anUnresolvableSourceIsNotABadgeRow() { |
| 311 | #expect(badges("[[https://example.com][file:badge.png]]") == nil) |
| 312 | } |
| 313 | } |